Compare commits
53 Commits
adb1cc81ef
...
feat/proje
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
81fe6d29e2 | ||
|
|
b2d7fa1723 | ||
|
|
4c641ab93a | ||
|
|
84720ff23c | ||
|
|
d7307e146a | ||
|
|
7d4059ca4b | ||
|
|
9691842e79 | ||
|
|
094840e671 | ||
|
|
e8592b25a8 | ||
|
|
27b385df53 | ||
|
|
e170844f17 | ||
|
|
27c1194384 | ||
|
|
26ea095f60 | ||
|
|
751d16a9f4 | ||
|
|
285214a2d2 | ||
|
|
89645f2abd | ||
|
|
7dadeb88fe | ||
|
|
13531fec40 | ||
|
|
e254efd420 | ||
|
|
6d79911414 | ||
|
|
69a859e19f | ||
|
|
098ce86c76 | ||
|
|
9ef809ba02 | ||
|
|
024d572ebb | ||
|
|
d24f09bbea | ||
|
|
56fe6c0754 | ||
|
|
c76de207d7 | ||
|
|
4e89a7a96c | ||
|
|
0fc3aa421e | ||
|
|
c10fbe22d7 | ||
|
|
e3e0b06fb6 | ||
|
|
b3d85b93f1 | ||
|
|
659607a1e9 | ||
|
|
80a0d2c56f | ||
|
|
66448a25f4 | ||
|
|
93144b9de8 | ||
|
|
b0c415f90f | ||
|
|
8a2225da7c | ||
|
|
e0c5971d20 | ||
|
|
a499d55636 | ||
|
|
c36890cc8b | ||
|
|
b80ba0434b | ||
|
|
01d3735dd1 | ||
|
|
e0bcb2fe0a | ||
|
|
a1c83a6134 | ||
|
|
bd5e3076ed | ||
|
|
316b8fa66a | ||
|
|
6f907f6a96 | ||
|
|
93caf0116d | ||
|
|
15af8d54e6 | ||
|
|
c4ed7b3482 | ||
|
|
066d407a5f | ||
|
|
c2826ae4be |
@@ -163,6 +163,16 @@ export class ServerError extends Error {
|
||||
}
|
||||
}
|
||||
|
||||
export class QuotaError extends Error {
|
||||
constructor(
|
||||
public readonly reason: 'max_files' | 'monthly_tokens',
|
||||
message: string,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'QuotaError';
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// V3 stream listener types
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -183,6 +193,12 @@ interface JourneyListener {
|
||||
reject: (err: Error) => void;
|
||||
}
|
||||
|
||||
export interface IndexSessionListener {
|
||||
onFileResult: (frame: { relPath: string; summary: string | null; tokensUsed: number; error?: string }) => void;
|
||||
onProgress: (frame: { processed: number; total: number }) => void;
|
||||
onDone: (status: 'completed' | 'cancelled' | 'quota_exceeded' | 'error') => void;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// BackendClient
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -206,6 +222,9 @@ export class BackendClient {
|
||||
/** Journey reply listeners keyed by sessionId. */
|
||||
private journeyListeners: Map<string, JourneyListener> = new Map();
|
||||
|
||||
/** Index session listeners keyed by sessionId. */
|
||||
private indexListeners: Map<string, IndexSessionListener> = new Map();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
private constructor() {}
|
||||
|
||||
@@ -564,6 +583,64 @@ export class BackendClient {
|
||||
});
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Index session WS methods
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Register a listener for an index session. Must be called before sending
|
||||
* the first `index_session_start` for that sessionId. Listener is auto-
|
||||
* removed when `index_session_done` is received.
|
||||
*/
|
||||
registerIndexSession(sessionId: string, listener: IndexSessionListener): void {
|
||||
this.indexListeners.set(sessionId, listener);
|
||||
}
|
||||
|
||||
/**
|
||||
* Send the opening `index_session_start` frame. Must be called after
|
||||
* `registerIndexSession`. Throws OfflineError if the WS is not connected.
|
||||
*/
|
||||
sendIndexSessionStart(sessionId: string, projectId: string, totalFiles: number): void {
|
||||
const ws = this.persistentWs;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) throw new OfflineError('Persistent WS not connected');
|
||||
const payload = toSnakeCase({ type: 'index_session_start', sessionId, projectId, totalFiles });
|
||||
logWsSend(payload);
|
||||
ws.send(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
/**
|
||||
* Send one batch of files (typically up to 5).
|
||||
*/
|
||||
sendIndexFileBatch(
|
||||
sessionId: string,
|
||||
files: Array<{
|
||||
relPath: string;
|
||||
kind: 'text' | 'image' | 'pdf' | 'docx';
|
||||
content: string;
|
||||
ext?: string;
|
||||
mime?: string;
|
||||
sizeBytes: number;
|
||||
mtimeMs: number;
|
||||
}>,
|
||||
): void {
|
||||
const ws = this.persistentWs;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) throw new OfflineError('Persistent WS not connected');
|
||||
const payload = toSnakeCase({ type: 'index_file_batch', sessionId, files });
|
||||
logWsSend(payload);
|
||||
ws.send(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
/**
|
||||
* Cancel an in-flight index session.
|
||||
*/
|
||||
sendIndexSessionCancel(sessionId: string): void {
|
||||
const ws = this.persistentWs;
|
||||
if (!ws || ws.readyState !== WebSocket.OPEN) return; // best-effort
|
||||
const payload = toSnakeCase({ type: 'index_session_cancel', sessionId });
|
||||
logWsSend(payload);
|
||||
ws.send(JSON.stringify(payload));
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// HTTP utilities
|
||||
// -------------------------------------------------------------------------
|
||||
@@ -678,6 +755,72 @@ export class BackendClient {
|
||||
);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Billing quota pre-flight
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Pre-flight quota check for folder indexing.
|
||||
*
|
||||
* Calls `POST /api/v1/billing/quota/check` with `{ feature: "folder_index",
|
||||
* estimated_files: estimatedFiles }`.
|
||||
*
|
||||
* Returns `{ ok: true }` when the backend allows the operation.
|
||||
* Throws `QuotaError` when the backend responds with HTTP 402.
|
||||
* Propagates `AuthExpiredError` / `OfflineError` on auth / network failure.
|
||||
*/
|
||||
async checkFolderQuota(estimatedFiles: number): Promise<{ ok: true }> {
|
||||
const token = await getAuthManager().getAccessToken();
|
||||
if (!token) throw new AuthExpiredError();
|
||||
|
||||
const url = `${this.baseUrl}/api/v1/billing/quota/check`;
|
||||
const bodyPayload = { feature: 'folder_index', estimated_files: estimatedFiles };
|
||||
logHttp('POST', url, bodyPayload);
|
||||
|
||||
let res: Response;
|
||||
try {
|
||||
res = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
body: JSON.stringify(bodyPayload),
|
||||
signal: AbortSignal.timeout(HTTP_TIMEOUT_MS),
|
||||
});
|
||||
} catch (err) {
|
||||
throw new OfflineError(err instanceof Error ? err.message : 'Network error');
|
||||
}
|
||||
|
||||
logHttpResponse('POST', url, res.status);
|
||||
|
||||
if (res.ok) return { ok: true };
|
||||
|
||||
if (res.status === 402) {
|
||||
let detail: { reason: 'max_files' | 'monthly_tokens'; message: string } | null = null;
|
||||
try {
|
||||
const body = await res.json() as { detail?: { reason: string; message: string } };
|
||||
if (body.detail?.reason && body.detail?.message) {
|
||||
detail = {
|
||||
reason: body.detail.reason as 'max_files' | 'monthly_tokens',
|
||||
message: body.detail.message,
|
||||
};
|
||||
}
|
||||
} catch { /* ignore parse errors */ }
|
||||
const reason = detail?.reason ?? 'max_files';
|
||||
const message = detail?.message ?? 'Quota exceeded';
|
||||
throw new QuotaError(reason, message);
|
||||
}
|
||||
|
||||
// Other error codes
|
||||
const text = await res.text().catch(() => '');
|
||||
const msg = `${res.status} ${res.statusText}${text ? `: ${text}` : ''}`;
|
||||
if (res.status === 401) throw new AuthExpiredError(msg);
|
||||
if (res.status === 429) throw new RateLimitError(msg);
|
||||
if (res.status >= 500) throw new ServerError(msg, res.status);
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
@@ -861,6 +1004,32 @@ export class BackendClient {
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'index_file_result': {
|
||||
const lis = this.indexListeners.get(frame.data.sessionId);
|
||||
lis?.onFileResult({
|
||||
relPath: frame.data.relPath,
|
||||
summary: frame.data.summary ?? null,
|
||||
tokensUsed: frame.data.tokensUsed,
|
||||
error: frame.data.error,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
case 'index_session_progress': {
|
||||
const lis = this.indexListeners.get(frame.data.sessionId);
|
||||
lis?.onProgress({ processed: frame.data.processed, total: frame.data.total });
|
||||
break;
|
||||
}
|
||||
|
||||
case 'index_session_done': {
|
||||
const lis = this.indexListeners.get(frame.data.sessionId);
|
||||
if (lis) {
|
||||
lis.onDone(frame.data.status);
|
||||
this.indexListeners.delete(frame.data.sessionId);
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
@@ -895,6 +1064,8 @@ export class BackendClient {
|
||||
}
|
||||
this.journeyListeners.clear();
|
||||
}
|
||||
// Index session listeners are fire-and-forget; just drop them on disconnect.
|
||||
this.indexListeners.clear();
|
||||
|
||||
if (this.shouldReconnect) {
|
||||
this.scheduleReconnect();
|
||||
|
||||
@@ -15,7 +15,7 @@ import * as fs from 'fs';
|
||||
import * as path from 'path';
|
||||
import { eq, and, or, like, isNull, asc, desc, gte, lte, sql, SQL } from 'drizzle-orm';
|
||||
import { getDb } from '../db';
|
||||
import { tasks, projects, clients, timelineEvents, notes, noteEdits, taskComments } from '../db/schema';
|
||||
import { tasks, projects, clients, timelineEvents, notes, noteEdits, taskComments, projectFolderFiles } from '../db/schema';
|
||||
import type { WsToolCall } from '../../shared/api-types';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -31,6 +31,7 @@ const TABLE_REGISTRY = {
|
||||
timelineEvents,
|
||||
// Alias: the backend sends "timelines" as the table name
|
||||
timelines: timelineEvents,
|
||||
projectFolderFiles,
|
||||
} as const;
|
||||
|
||||
type TableName = keyof typeof TABLE_REGISTRY;
|
||||
@@ -187,6 +188,12 @@ export class DrizzleExecutor {
|
||||
return this.handleReadFileContent(payload);
|
||||
case 'get_file_metadata':
|
||||
return this.handleGetFileMetadata(payload);
|
||||
case 'read_project_folder_manifest':
|
||||
return this.handleReadProjectFolderManifest(payload);
|
||||
case 'read_project_folder_file':
|
||||
return this.handleReadProjectFolderFile(payload);
|
||||
case 'list_projects_with_folder_manifests':
|
||||
return this.handleListProjectsWithFolderManifests();
|
||||
default:
|
||||
throw new ExecutorError(`Unknown action: "${action as string}"`);
|
||||
}
|
||||
@@ -436,4 +443,143 @@ export class DrizzleExecutor {
|
||||
modifiedAt: stat.mtime.toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
// -------------------------------------------------------------------------
|
||||
// Project folder handlers
|
||||
// -------------------------------------------------------------------------
|
||||
|
||||
private handleReadProjectFolderManifest(payload: WsToolCall): Record<string, unknown> {
|
||||
const { projectId } = (payload.data ?? {}) as { projectId: string };
|
||||
const proj = getDb().select().from(projects).where(eq(projects.id, projectId)).get();
|
||||
if (!proj?.folderPath) return { folderPath: null, lastScannedAt: null, files: [] };
|
||||
|
||||
const files = getDb()
|
||||
.select({
|
||||
relPath: projectFolderFiles.relativePath,
|
||||
kind: projectFolderFiles.kind,
|
||||
summary: projectFolderFiles.summary,
|
||||
mtimeMs: projectFolderFiles.mtimeMs,
|
||||
})
|
||||
.from(projectFolderFiles)
|
||||
.where(eq(projectFolderFiles.projectId, projectId))
|
||||
.all();
|
||||
|
||||
// On-demand mtime check: if not currently scanning, fire-and-forget rescan when deltas exist.
|
||||
// Returns the current (possibly stale) manifest immediately; the rescan updates rows
|
||||
// for the next call.
|
||||
if (proj.folderLastScanStatus !== 'scanning') {
|
||||
void import('../files/scanner')
|
||||
.then(async ({ scanFolder }) => {
|
||||
const delta = await scanFolder(projectId, proj.folderPath!);
|
||||
if (
|
||||
delta.newFiles.length > 0 ||
|
||||
delta.changedFiles.length > 0 ||
|
||||
delta.deletedRelPaths.length > 0
|
||||
) {
|
||||
const { startIndexSession } = await import('../files/indexer');
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
void startIndexSession(projectId, () => {});
|
||||
}
|
||||
})
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
.catch(() => {});
|
||||
}
|
||||
|
||||
return {
|
||||
folderPath: proj.folderPath,
|
||||
lastScannedAt: proj.folderLastScannedAt,
|
||||
files,
|
||||
};
|
||||
}
|
||||
|
||||
private async handleReadProjectFolderFile(payload: WsToolCall): Promise<Record<string, unknown>> {
|
||||
const { projectId, relativePath, offset, length } = (payload.data ?? {}) as {
|
||||
projectId: string;
|
||||
relativePath: string;
|
||||
offset?: number;
|
||||
length?: number;
|
||||
};
|
||||
|
||||
if (!relativePath || relativePath.includes('..') || path.isAbsolute(relativePath)) {
|
||||
throw new ExecutorError('Access denied');
|
||||
}
|
||||
|
||||
const proj = getDb().select().from(projects).where(eq(projects.id, projectId)).get();
|
||||
if (!proj?.folderPath) return { content: '', kind: 'missing', totalSize: 0 };
|
||||
|
||||
const abs = path.join(proj.folderPath, relativePath);
|
||||
if (!path.resolve(abs).startsWith(path.resolve(proj.folderPath))) {
|
||||
throw new ExecutorError('Access denied');
|
||||
}
|
||||
|
||||
try {
|
||||
const stat = await fs.promises.stat(abs);
|
||||
const ext = path.extname(relativePath).toLowerCase();
|
||||
|
||||
if (['.png', '.jpg', '.jpeg', '.webp'].includes(ext)) {
|
||||
const buf = await fs.promises.readFile(abs);
|
||||
return { content: buf.toString('base64'), kind: 'image', totalSize: stat.size };
|
||||
}
|
||||
|
||||
// PDF + DOCX: return full base64; backend extracts text + slices.
|
||||
if (ext === '.pdf' || ext === '.docx') {
|
||||
const buf = await fs.promises.readFile(abs);
|
||||
return {
|
||||
content: buf.toString('base64'),
|
||||
kind: ext === '.pdf' ? 'pdf' : 'docx',
|
||||
totalSize: stat.size,
|
||||
};
|
||||
}
|
||||
|
||||
// Text: slice at offset/length on Electron side to keep WS payload small.
|
||||
const start = Math.max(0, offset ?? 0);
|
||||
const want = Math.max(1, Math.min(length ?? MAX_READ_SIZE_BYTES, MAX_READ_SIZE_BYTES));
|
||||
const end = Math.min(start + want, stat.size);
|
||||
const len = Math.max(0, end - start);
|
||||
if (len === 0) {
|
||||
return { content: '', kind: 'text', totalSize: stat.size };
|
||||
}
|
||||
const buf = Buffer.alloc(len);
|
||||
const fd = await fs.promises.open(abs, 'r');
|
||||
try {
|
||||
await fd.read(buf, 0, len, start);
|
||||
} finally {
|
||||
await fd.close();
|
||||
}
|
||||
return { content: buf.toString('utf8'), kind: 'text', totalSize: stat.size };
|
||||
} catch {
|
||||
return { content: '', kind: 'error', totalSize: 0 };
|
||||
}
|
||||
}
|
||||
|
||||
private handleListProjectsWithFolderManifests(): Record<string, unknown> {
|
||||
const projs = getDb()
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(sql`${projects.folderPath} IS NOT NULL`)
|
||||
.all();
|
||||
|
||||
const out: Array<unknown> = [];
|
||||
for (const p of projs) {
|
||||
const files = getDb()
|
||||
.select({
|
||||
relPath: projectFolderFiles.relativePath,
|
||||
kind: projectFolderFiles.kind,
|
||||
summary: projectFolderFiles.summary,
|
||||
mtimeMs: projectFolderFiles.mtimeMs,
|
||||
})
|
||||
.from(projectFolderFiles)
|
||||
.where(eq(projectFolderFiles.projectId, p.id))
|
||||
.all();
|
||||
out.push({
|
||||
projectId: p.id,
|
||||
projectName: p.name,
|
||||
folderPath: p.folderPath,
|
||||
lastScannedAt: p.folderLastScannedAt,
|
||||
files,
|
||||
});
|
||||
}
|
||||
|
||||
return { projects: out };
|
||||
}
|
||||
}
|
||||
|
||||
16
src/main/db/migrations/0005_slim_baron_strucker.sql
Normal file
16
src/main/db/migrations/0005_slim_baron_strucker.sql
Normal file
@@ -0,0 +1,16 @@
|
||||
CREATE TABLE `project_folder_files` (
|
||||
`id` text PRIMARY KEY NOT NULL,
|
||||
`project_id` text NOT NULL,
|
||||
`relative_path` text NOT NULL,
|
||||
`ext` text NOT NULL,
|
||||
`kind` text NOT NULL,
|
||||
`size_bytes` integer NOT NULL,
|
||||
`mtime_ms` integer NOT NULL,
|
||||
`summary` text,
|
||||
`summary_updated_at` integer
|
||||
);
|
||||
--> statement-breakpoint
|
||||
ALTER TABLE `projects` ADD `folder_path` text;--> statement-breakpoint
|
||||
ALTER TABLE `projects` ADD `folder_last_scanned_at` integer;--> statement-breakpoint
|
||||
ALTER TABLE `projects` ADD `folder_last_scan_status` text DEFAULT 'idle';--> statement-breakpoint
|
||||
ALTER TABLE `projects` ADD `folder_total_files` integer DEFAULT 0 NOT NULL;
|
||||
934
src/main/db/migrations/meta/0005_snapshot.json
Normal file
934
src/main/db/migrations/meta/0005_snapshot.json
Normal file
@@ -0,0 +1,934 @@
|
||||
{
|
||||
"version": "6",
|
||||
"dialect": "sqlite",
|
||||
"id": "db432653-ac1d-40f4-b7eb-216d054ae191",
|
||||
"prevId": "8127cd67-44d0-41e8-a146-12eb1311c6c1",
|
||||
"tables": {
|
||||
"agent_run_actions": {
|
||||
"name": "agent_run_actions",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"run_id": {
|
||||
"name": "run_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"agent_id": {
|
||||
"name": "agent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"verb": {
|
||||
"name": "verb",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"entity_type": {
|
||||
"name": "entity_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"entity_id": {
|
||||
"name": "entity_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"entity_title": {
|
||||
"name": "entity_title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"agent_runs": {
|
||||
"name": "agent_runs",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"agent_id": {
|
||||
"name": "agent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'running'"
|
||||
},
|
||||
"started_at": {
|
||||
"name": "started_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"clients": {
|
||||
"name": "clients",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"parent_id": {
|
||||
"name": "parent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"industry": {
|
||||
"name": "industry",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"note_edits": {
|
||||
"name": "note_edits",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"note_id": {
|
||||
"name": "note_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"anchor_before": {
|
||||
"name": "anchor_before",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"anchor_text": {
|
||||
"name": "anchor_text",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"proposed_content": {
|
||||
"name": "proposed_content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'pending'"
|
||||
},
|
||||
"agent_id": {
|
||||
"name": "agent_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"run_id": {
|
||||
"name": "run_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"reasoning": {
|
||||
"name": "reasoning",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"resolved_at": {
|
||||
"name": "resolved_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"notes": {
|
||||
"name": "notes",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "''"
|
||||
},
|
||||
"ai_summary": {
|
||||
"name": "ai_summary",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"ai_summary_updated_at": {
|
||||
"name": "ai_summary_updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"updated_at": {
|
||||
"name": "updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"project_folder_files": {
|
||||
"name": "project_folder_files",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"relative_path": {
|
||||
"name": "relative_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"ext": {
|
||||
"name": "ext",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"kind": {
|
||||
"name": "kind",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"size_bytes": {
|
||||
"name": "size_bytes",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"mtime_ms": {
|
||||
"name": "mtime_ms",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"summary": {
|
||||
"name": "summary",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"summary_updated_at": {
|
||||
"name": "summary_updated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"projects": {
|
||||
"name": "projects",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"client_id": {
|
||||
"name": "client_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"name": {
|
||||
"name": "name",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'active'"
|
||||
},
|
||||
"ai_summary": {
|
||||
"name": "ai_summary",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"folder_path": {
|
||||
"name": "folder_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"folder_last_scanned_at": {
|
||||
"name": "folder_last_scanned_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"folder_last_scan_status": {
|
||||
"name": "folder_last_scan_status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false,
|
||||
"default": "'idle'"
|
||||
},
|
||||
"folder_total_files": {
|
||||
"name": "folder_total_files",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"task_attachments": {
|
||||
"name": "task_attachments",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"filename": {
|
||||
"name": "filename",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"mime_type": {
|
||||
"name": "mime_type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"size_bytes": {
|
||||
"name": "size_bytes",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"stored_path": {
|
||||
"name": "stored_path",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"task_brief_chats": {
|
||||
"name": "task_brief_chats",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"role": {
|
||||
"name": "role",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"is_error": {
|
||||
"name": "is_error",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"task_briefings": {
|
||||
"name": "task_briefings",
|
||||
"columns": {
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"briefing_markdown": {
|
||||
"name": "briefing_markdown",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"canvas_draft": {
|
||||
"name": "canvas_draft",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"canvas_kind": {
|
||||
"name": "canvas_kind",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"citations": {
|
||||
"name": "citations",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"source_task_hash": {
|
||||
"name": "source_task_hash",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"generated_at": {
|
||||
"name": "generated_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"model_version": {
|
||||
"name": "model_version",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"task_comments": {
|
||||
"name": "task_comments",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"task_id": {
|
||||
"name": "task_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"author": {
|
||||
"name": "author",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"content": {
|
||||
"name": "content",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"tasks": {
|
||||
"name": "tasks",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"description": {
|
||||
"name": "description",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"status": {
|
||||
"name": "status",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'todo'"
|
||||
},
|
||||
"priority": {
|
||||
"name": "priority",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'medium'"
|
||||
},
|
||||
"assignee": {
|
||||
"name": "assignee",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"due_date": {
|
||||
"name": "due_date",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"estimate": {
|
||||
"name": "estimate",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"is_ai_suggested": {
|
||||
"name": "is_ai_suggested",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"timeline_event_dependencies": {
|
||||
"name": "timeline_event_dependencies",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"from_event_id": {
|
||||
"name": "from_event_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"to_event_id": {
|
||||
"name": "to_event_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
},
|
||||
"timeline_events": {
|
||||
"name": "timeline_events",
|
||||
"columns": {
|
||||
"id": {
|
||||
"name": "id",
|
||||
"type": "text",
|
||||
"primaryKey": true,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"project_id": {
|
||||
"name": "project_id",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"title": {
|
||||
"name": "title",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"date": {
|
||||
"name": "date",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"end_date": {
|
||||
"name": "end_date",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
},
|
||||
"type": {
|
||||
"name": "type",
|
||||
"type": "text",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": "'milestone'"
|
||||
},
|
||||
"is_completed": {
|
||||
"name": "is_completed",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"is_ai_suggested": {
|
||||
"name": "is_ai_suggested",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false,
|
||||
"default": 0
|
||||
},
|
||||
"created_at": {
|
||||
"name": "created_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": true,
|
||||
"autoincrement": false
|
||||
},
|
||||
"completed_at": {
|
||||
"name": "completed_at",
|
||||
"type": "integer",
|
||||
"primaryKey": false,
|
||||
"notNull": false,
|
||||
"autoincrement": false
|
||||
}
|
||||
},
|
||||
"indexes": {},
|
||||
"foreignKeys": {},
|
||||
"compositePrimaryKeys": {},
|
||||
"uniqueConstraints": {},
|
||||
"checkConstraints": {}
|
||||
}
|
||||
},
|
||||
"views": {},
|
||||
"enums": {},
|
||||
"_meta": {
|
||||
"schemas": {},
|
||||
"tables": {},
|
||||
"columns": {}
|
||||
},
|
||||
"internal": {
|
||||
"indexes": {}
|
||||
}
|
||||
}
|
||||
@@ -36,6 +36,13 @@
|
||||
"when": 1778238659431,
|
||||
"tag": "0004_right_alex_power",
|
||||
"breakpoints": true
|
||||
},
|
||||
{
|
||||
"idx": 5,
|
||||
"version": "6",
|
||||
"when": 1778579196669,
|
||||
"tag": "0005_slim_baron_strucker",
|
||||
"breakpoints": true
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -16,6 +16,12 @@ export const projects = sqliteTable('projects', {
|
||||
status: text('status', { enum: ['active', 'archived'] }).notNull().default('active'),
|
||||
aiSummary: text('ai_summary'),
|
||||
createdAt: integer('created_at', { mode: 'number' }).notNull(),
|
||||
folderPath: text('folder_path'),
|
||||
folderLastScannedAt: integer('folder_last_scanned_at', { mode: 'number' }),
|
||||
folderLastScanStatus: text('folder_last_scan_status', {
|
||||
enum: ['idle', 'scanning', 'error'],
|
||||
}).default('idle'),
|
||||
folderTotalFiles: integer('folder_total_files', { mode: 'number' }).notNull().default(0),
|
||||
});
|
||||
|
||||
export const tasks = sqliteTable('tasks', {
|
||||
@@ -64,6 +70,21 @@ export const notes = sqliteTable('notes', {
|
||||
updatedAt: integer('updated_at', { mode: 'number' }).notNull(),
|
||||
});
|
||||
|
||||
export const projectFolderFiles = sqliteTable('project_folder_files', {
|
||||
id: text('id').primaryKey(),
|
||||
projectId: text('project_id').notNull(),
|
||||
relativePath: text('relative_path').notNull(),
|
||||
ext: text('ext').notNull(),
|
||||
kind: text('kind', { enum: ['text', 'image', 'pdf', 'docx', 'csv', 'skipped', 'error'] }).notNull(),
|
||||
sizeBytes: integer('size_bytes', { mode: 'number' }).notNull(),
|
||||
mtimeMs: integer('mtime_ms', { mode: 'number' }).notNull(),
|
||||
summary: text('summary'),
|
||||
summaryUpdatedAt: integer('summary_updated_at', { mode: 'number' }),
|
||||
});
|
||||
|
||||
export type ProjectFolderFile = InferSelectModel<typeof projectFolderFiles>;
|
||||
export type NewProjectFolderFile = InferInsertModel<typeof projectFolderFiles>;
|
||||
|
||||
export const noteEdits = sqliteTable('note_edits', {
|
||||
id: text('id').primaryKey(),
|
||||
noteId: text('note_id').notNull(),
|
||||
|
||||
21
src/main/files/constants.ts
Normal file
21
src/main/files/constants.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/** File-type whitelists & size caps for project folder indexing. */
|
||||
|
||||
export const TEXT_EXTS = new Set([
|
||||
'.md', '.txt', '.rst', '.adoc',
|
||||
'.json', '.yaml', '.yml', '.toml', '.ini', '.csv', '.tsv',
|
||||
'.html', '.htm', '.xml',
|
||||
'.js', '.jsx', '.ts', '.tsx', '.mjs', '.cjs',
|
||||
'.py', '.rb', '.go', '.rs', '.java', '.kt', '.swift',
|
||||
'.c', '.h', '.cpp', '.hpp', '.cs', '.php', '.sh', '.ps1',
|
||||
'.css', '.scss', '.sass',
|
||||
'.sql',
|
||||
]);
|
||||
|
||||
export const IMAGE_EXTS = new Set(['.png', '.jpg', '.jpeg', '.webp']);
|
||||
|
||||
export const PDF_EXTS = new Set(['.pdf']);
|
||||
export const DOCX_EXTS = new Set(['.docx']);
|
||||
|
||||
export const MAX_TEXT_FILE_BYTES = 1 * 1024 * 1024; // 1 MB
|
||||
export const MAX_IMAGE_FILE_BYTES = 5 * 1024 * 1024; // 5 MB
|
||||
export const INDEX_BATCH_SIZE = 5;
|
||||
27
src/main/files/daily-rescan.ts
Normal file
27
src/main/files/daily-rescan.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
// adiuvAI/src/main/files/daily-rescan.ts
|
||||
import { getDb } from '../db';
|
||||
import { projects } from '../db/schema';
|
||||
import { sql, and, isNotNull } from 'drizzle-orm';
|
||||
import { startIndexSession } from './indexer';
|
||||
|
||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
||||
|
||||
export async function runDailyRescan(): Promise<void> {
|
||||
const cutoff = Date.now() - ONE_DAY_MS;
|
||||
const stale = getDb()
|
||||
.select()
|
||||
.from(projects)
|
||||
.where(
|
||||
and(
|
||||
isNotNull(projects.folderPath),
|
||||
sql`(${projects.folderLastScannedAt} IS NULL OR ${projects.folderLastScannedAt} < ${cutoff})`,
|
||||
),
|
||||
)
|
||||
.all();
|
||||
for (const p of stale) {
|
||||
if (p.folderLastScanStatus === 'scanning') continue;
|
||||
// Fire-and-forget; no UI listener.
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
void startIndexSession(p.id, () => {});
|
||||
}
|
||||
}
|
||||
222
src/main/files/indexer.ts
Normal file
222
src/main/files/indexer.ts
Normal file
@@ -0,0 +1,222 @@
|
||||
/**
|
||||
* Folder index session orchestrator.
|
||||
*
|
||||
* Walks a folder via scanner.ts, sends batches over WS to the backend, applies
|
||||
* returned summaries to projectFolderFiles, drives progress callbacks.
|
||||
*/
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { randomUUID } from 'node:crypto';
|
||||
import { getDb } from '../db';
|
||||
import { projects, projectFolderFiles } from '../db/schema';
|
||||
import { eq, and } from 'drizzle-orm';
|
||||
import { scanFolder, type ScannedFile } from './scanner';
|
||||
import { INDEX_BATCH_SIZE } from './constants';
|
||||
import { getBackendClient } from '../api/backend-client';
|
||||
|
||||
export interface IndexProgress {
|
||||
sessionId: string;
|
||||
processed: number;
|
||||
total: number;
|
||||
status: 'starting' | 'scanning' | 'cancelled' | 'completed' | 'quota_exceeded' | 'error';
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export type ProgressListener = (p: IndexProgress) => void;
|
||||
|
||||
async function readForIndex(
|
||||
folderPath: string,
|
||||
f: ScannedFile,
|
||||
): Promise<{ content: string; mime?: string }> {
|
||||
const abs = path.join(folderPath, f.relativePath);
|
||||
if (f.kind === 'image') {
|
||||
const buf = await readFile(abs);
|
||||
const ext = f.ext.toLowerCase();
|
||||
const mime =
|
||||
ext === '.png' ? 'image/png' : ext === '.webp' ? 'image/webp' : 'image/jpeg';
|
||||
return { content: buf.toString('base64'), mime };
|
||||
}
|
||||
if (f.kind === 'text') {
|
||||
return { content: await readFile(abs, 'utf-8') };
|
||||
}
|
||||
// pdf / docx: read as binary, base64. Server is responsible for extraction.
|
||||
const buf = await readFile(abs);
|
||||
return { content: buf.toString('base64') };
|
||||
}
|
||||
|
||||
export async function startIndexSession(
|
||||
projectId: string,
|
||||
onProgress: ProgressListener,
|
||||
): Promise<{ sessionId: string; cancel: () => void }> {
|
||||
const sessionId = randomUUID();
|
||||
const db = getDb();
|
||||
|
||||
const proj = db.select().from(projects).where(eq(projects.id, projectId)).get();
|
||||
if (!proj || !proj.folderPath) {
|
||||
onProgress({ sessionId, processed: 0, total: 0, status: 'error', error: 'No folder linked' });
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
return { sessionId, cancel: () => {} };
|
||||
}
|
||||
|
||||
db.update(projects)
|
||||
.set({ folderLastScanStatus: 'scanning' })
|
||||
.where(eq(projects.id, projectId))
|
||||
.run();
|
||||
onProgress({ sessionId, processed: 0, total: 0, status: 'scanning' });
|
||||
|
||||
const delta = await scanFolder(projectId, proj.folderPath);
|
||||
|
||||
// Filter out 'skipped' files — they are too large to index and must not be sent
|
||||
const toIndex = [
|
||||
...delta.newFiles.filter((f) => f.kind !== 'skipped'),
|
||||
...delta.changedFiles.filter((f) => f.kind !== 'skipped'),
|
||||
];
|
||||
const total = toIndex.length;
|
||||
|
||||
for (const rel of delta.deletedRelPaths) {
|
||||
db.delete(projectFolderFiles)
|
||||
.where(
|
||||
and(
|
||||
eq(projectFolderFiles.projectId, projectId),
|
||||
eq(projectFolderFiles.relativePath, rel),
|
||||
),
|
||||
)
|
||||
.run();
|
||||
}
|
||||
|
||||
if (total === 0) {
|
||||
db.update(projects)
|
||||
.set({
|
||||
folderLastScanStatus: 'idle',
|
||||
folderLastScannedAt: Date.now(),
|
||||
folderTotalFiles: delta.unchangedCount,
|
||||
})
|
||||
.where(eq(projects.id, projectId))
|
||||
.run();
|
||||
onProgress({ sessionId, processed: 0, total: 0, status: 'completed' });
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
return { sessionId, cancel: () => {} };
|
||||
}
|
||||
|
||||
const backend = getBackendClient();
|
||||
|
||||
let processed = 0;
|
||||
let cancelled = false;
|
||||
|
||||
const finalize = (status: IndexProgress['status'], error?: string): void => {
|
||||
db.update(projects)
|
||||
.set({
|
||||
folderLastScanStatus:
|
||||
status === 'completed' || status === 'cancelled' ? 'idle' : 'error',
|
||||
folderLastScannedAt: Date.now(),
|
||||
folderTotalFiles: delta.unchangedCount + processed,
|
||||
})
|
||||
.where(eq(projects.id, projectId))
|
||||
.run();
|
||||
onProgress({ sessionId, processed, total, status, error });
|
||||
};
|
||||
|
||||
backend.registerIndexSession(sessionId, {
|
||||
onFileResult: ({ relPath, summary, error }) => {
|
||||
if (error) return;
|
||||
const f = toIndex.find((x) => x.relativePath === relPath);
|
||||
if (!f) return;
|
||||
const now = Date.now();
|
||||
|
||||
// SELECT-then-INSERT-or-UPDATE: no unique index on (projectId, relativePath)
|
||||
const existing = db
|
||||
.select()
|
||||
.from(projectFolderFiles)
|
||||
.where(
|
||||
and(
|
||||
eq(projectFolderFiles.projectId, projectId),
|
||||
eq(projectFolderFiles.relativePath, f.relativePath),
|
||||
),
|
||||
)
|
||||
.get();
|
||||
|
||||
if (existing) {
|
||||
db.update(projectFolderFiles)
|
||||
.set({
|
||||
mtimeMs: f.mtimeMs,
|
||||
sizeBytes: f.sizeBytes,
|
||||
kind: f.kind,
|
||||
summary: summary ?? null,
|
||||
summaryUpdatedAt: now,
|
||||
})
|
||||
.where(eq(projectFolderFiles.id, existing.id))
|
||||
.run();
|
||||
} else {
|
||||
db.insert(projectFolderFiles)
|
||||
.values({
|
||||
id: randomUUID(),
|
||||
projectId,
|
||||
relativePath: f.relativePath,
|
||||
ext: f.ext,
|
||||
kind: f.kind,
|
||||
sizeBytes: f.sizeBytes,
|
||||
mtimeMs: f.mtimeMs,
|
||||
summary: summary ?? null,
|
||||
summaryUpdatedAt: now,
|
||||
})
|
||||
.run();
|
||||
}
|
||||
},
|
||||
onProgress: ({ processed: p, total: t }) => {
|
||||
processed = p;
|
||||
onProgress({ sessionId, processed: p, total: t, status: 'scanning' });
|
||||
},
|
||||
onDone: (status) => {
|
||||
finalize(
|
||||
status === 'completed'
|
||||
? 'completed'
|
||||
: status === 'cancelled'
|
||||
? 'cancelled'
|
||||
: status === 'quota_exceeded'
|
||||
? 'quota_exceeded'
|
||||
: 'error',
|
||||
);
|
||||
},
|
||||
});
|
||||
|
||||
try {
|
||||
backend.sendIndexSessionStart(sessionId, projectId, total);
|
||||
} catch (err) {
|
||||
finalize('error', err instanceof Error ? err.message : 'WS send failed');
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
return { sessionId, cancel: () => {} };
|
||||
}
|
||||
|
||||
// Send batches (skipped files already excluded from toIndex)
|
||||
for (let i = 0; i < toIndex.length; i += INDEX_BATCH_SIZE) {
|
||||
if (cancelled) break;
|
||||
const batch = toIndex.slice(i, i + INDEX_BATCH_SIZE);
|
||||
const payload = await Promise.all(
|
||||
batch.map(async (f) => {
|
||||
const { content, mime } = await readForIndex(proj.folderPath!, f);
|
||||
return {
|
||||
relPath: f.relativePath,
|
||||
kind: f.kind as 'text' | 'image' | 'pdf' | 'docx',
|
||||
content,
|
||||
ext: f.ext,
|
||||
mime,
|
||||
sizeBytes: f.sizeBytes,
|
||||
mtimeMs: f.mtimeMs,
|
||||
};
|
||||
}),
|
||||
);
|
||||
try {
|
||||
backend.sendIndexFileBatch(sessionId, payload);
|
||||
} catch (err) {
|
||||
finalize('error', err instanceof Error ? err.message : 'WS send failed');
|
||||
// eslint-disable-next-line @typescript-eslint/no-empty-function
|
||||
return { sessionId, cancel: () => {} };
|
||||
}
|
||||
}
|
||||
|
||||
const cancel = (): void => {
|
||||
cancelled = true;
|
||||
backend.sendIndexSessionCancel(sessionId);
|
||||
};
|
||||
return { sessionId, cancel };
|
||||
}
|
||||
95
src/main/files/scanner.ts
Normal file
95
src/main/files/scanner.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
/** Filesystem scanner — walks a directory, filters by whitelist, computes delta vs DB manifest. */
|
||||
|
||||
import { readdir, stat } from 'node:fs/promises';
|
||||
import path from 'node:path';
|
||||
import { getDb } from '../db';
|
||||
import { projectFolderFiles } from '../db/schema';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import {
|
||||
TEXT_EXTS, IMAGE_EXTS, PDF_EXTS, DOCX_EXTS,
|
||||
MAX_TEXT_FILE_BYTES, MAX_IMAGE_FILE_BYTES,
|
||||
} from './constants';
|
||||
|
||||
export type FileKind = 'text' | 'image' | 'pdf' | 'docx' | 'skipped';
|
||||
|
||||
export interface ScannedFile {
|
||||
relativePath: string;
|
||||
ext: string;
|
||||
kind: FileKind;
|
||||
sizeBytes: number;
|
||||
mtimeMs: number;
|
||||
}
|
||||
|
||||
export interface ScanDelta {
|
||||
newFiles: ScannedFile[];
|
||||
changedFiles: ScannedFile[];
|
||||
unchangedCount: number;
|
||||
deletedRelPaths: string[];
|
||||
}
|
||||
|
||||
function classify(ext: string, sizeBytes: number): FileKind | null {
|
||||
const e = ext.toLowerCase();
|
||||
if (TEXT_EXTS.has(e)) return sizeBytes <= MAX_TEXT_FILE_BYTES ? 'text' : 'skipped';
|
||||
if (IMAGE_EXTS.has(e)) return sizeBytes <= MAX_IMAGE_FILE_BYTES ? 'image' : 'skipped';
|
||||
if (PDF_EXTS.has(e)) return sizeBytes <= MAX_TEXT_FILE_BYTES ? 'pdf' : 'skipped';
|
||||
if (DOCX_EXTS.has(e)) return sizeBytes <= MAX_TEXT_FILE_BYTES ? 'docx' : 'skipped';
|
||||
return null; // not indexable
|
||||
}
|
||||
|
||||
async function walk(root: string): Promise<ScannedFile[]> {
|
||||
const out: ScannedFile[] = [];
|
||||
async function recurse(dir: string) {
|
||||
let entries;
|
||||
try {
|
||||
entries = await readdir(dir, { withFileTypes: true });
|
||||
} catch {
|
||||
return; // permission denied — skip silently
|
||||
}
|
||||
for (const e of entries) {
|
||||
if (e.name.startsWith('.')) continue; // skip dot dirs / files
|
||||
if (e.name === 'node_modules') continue; // common noise
|
||||
const full = path.join(dir, e.name);
|
||||
if (e.isDirectory()) {
|
||||
await recurse(full);
|
||||
} else if (e.isFile()) {
|
||||
let s;
|
||||
try { s = await stat(full); } catch { continue; }
|
||||
const ext = path.extname(e.name);
|
||||
const kind = classify(ext, s.size);
|
||||
if (kind === null) continue;
|
||||
out.push({
|
||||
relativePath: path.relative(root, full),
|
||||
ext,
|
||||
kind,
|
||||
sizeBytes: s.size,
|
||||
mtimeMs: Math.floor(s.mtimeMs),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
await recurse(root);
|
||||
return out;
|
||||
}
|
||||
|
||||
export async function scanFolder(projectId: string, folderPath: string): Promise<ScanDelta> {
|
||||
const scanned = await walk(folderPath);
|
||||
const existing = getDb()
|
||||
.select()
|
||||
.from(projectFolderFiles)
|
||||
.where(eq(projectFolderFiles.projectId, projectId))
|
||||
.all();
|
||||
|
||||
const existingMap = new Map(existing.map(r => [r.relativePath, r]));
|
||||
const newFiles: ScannedFile[] = [];
|
||||
const changedFiles: ScannedFile[] = [];
|
||||
let unchanged = 0;
|
||||
for (const f of scanned) {
|
||||
const prev = existingMap.get(f.relativePath);
|
||||
if (!prev) newFiles.push(f);
|
||||
else if (prev.mtimeMs !== f.mtimeMs || prev.sizeBytes !== f.sizeBytes) changedFiles.push(f);
|
||||
else unchanged++;
|
||||
existingMap.delete(f.relativePath);
|
||||
}
|
||||
const deletedRelPaths = Array.from(existingMap.keys());
|
||||
return { newFiles, changedFiles, unchangedCount: unchanged, deletedRelPaths };
|
||||
}
|
||||
@@ -10,6 +10,7 @@ import { getStore } from './store';
|
||||
import { startBriefScheduler, stopBriefScheduler } from './ai/orchestrator';
|
||||
import { startAgentScheduler, stopAgentScheduler } from './agents/agent-scheduler';
|
||||
import { backfillNoteSummaries } from './db/notes-backfill';
|
||||
import { runDailyRescan } from './files/daily-rescan';
|
||||
|
||||
// Handle creating/removing shortcuts on Windows when installing/uninstalling.
|
||||
if (started) {
|
||||
@@ -134,6 +135,8 @@ app.on('ready', () => {
|
||||
|
||||
startBriefScheduler();
|
||||
startAgentScheduler();
|
||||
// Delay so WS connection is likely up before triggering rescans
|
||||
setTimeout(() => { void runDailyRescan(); }, 10_000);
|
||||
});
|
||||
|
||||
// Clean up the persistent WS and backup timers before the app exits
|
||||
|
||||
@@ -17,6 +17,7 @@ import { orchestrate, orchestrateFloating, orchestrateTaskBriefResearch, dailyBr
|
||||
import { getAuthManager, AuthError } from '../auth/auth-manager';
|
||||
import { detectFormatPrefs, detectLanguage } from '../auth/locale-defaults';
|
||||
import type { TRPCContext } from '../ipc';
|
||||
import { projectFoldersRouter } from './projectFolders';
|
||||
|
||||
const t = initTRPC.context<TRPCContext>().create();
|
||||
|
||||
@@ -1854,6 +1855,7 @@ export const appRouter = router({
|
||||
auth: authRouter,
|
||||
agent: agentRouter,
|
||||
memory: memoryRouter,
|
||||
projectFolders: projectFoldersRouter,
|
||||
});
|
||||
|
||||
export type AppRouter = typeof appRouter;
|
||||
|
||||
128
src/main/router/projectFolders.ts
Normal file
128
src/main/router/projectFolders.ts
Normal file
@@ -0,0 +1,128 @@
|
||||
// adiuvAI/src/main/router/projectFolders.ts
|
||||
import { TRPCError, initTRPC } from '@trpc/server';
|
||||
import { z } from 'zod';
|
||||
import { dialog } from 'electron';
|
||||
import { eq } from 'drizzle-orm';
|
||||
import { getDb } from '../db';
|
||||
import { projects, projectFolderFiles } from '../db/schema';
|
||||
import { startIndexSession, type IndexProgress } from '../files/indexer';
|
||||
import { scanFolder } from '../files/scanner';
|
||||
import { getBackendClient, QuotaError } from '../api/backend-client';
|
||||
import type { TRPCContext } from '../ipc';
|
||||
|
||||
const t = initTRPC.context<TRPCContext>().create();
|
||||
const router = t.router;
|
||||
const publicProcedure = t.procedure;
|
||||
|
||||
// In-memory map of active sessions per projectId so we can cancel
|
||||
const _active = new Map<string, { cancel: () => void; lastProgress: IndexProgress }>();
|
||||
|
||||
export const projectFoldersRouter = router({
|
||||
chooseFolder: publicProcedure.mutation(async () => {
|
||||
const result = await dialog.showOpenDialog({ properties: ['openDirectory'] });
|
||||
if (result.canceled || result.filePaths.length === 0) return null;
|
||||
return result.filePaths[0];
|
||||
}),
|
||||
|
||||
link: publicProcedure
|
||||
.input(z.object({ projectId: z.string(), folderPath: z.string() }))
|
||||
.mutation(({ input }) => {
|
||||
const db = getDb();
|
||||
db.update(projects)
|
||||
.set({ folderPath: input.folderPath, folderLastScanStatus: 'idle', folderTotalFiles: 0 })
|
||||
.where(eq(projects.id, input.projectId))
|
||||
.run();
|
||||
return { ok: true };
|
||||
}),
|
||||
|
||||
unlink: publicProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.mutation(({ input }) => {
|
||||
const db = getDb();
|
||||
db.delete(projectFolderFiles).where(eq(projectFolderFiles.projectId, input.projectId)).run();
|
||||
db.update(projects)
|
||||
.set({
|
||||
folderPath: null,
|
||||
folderLastScannedAt: null,
|
||||
folderLastScanStatus: 'idle',
|
||||
folderTotalFiles: 0,
|
||||
})
|
||||
.where(eq(projects.id, input.projectId))
|
||||
.run();
|
||||
return { ok: true };
|
||||
}),
|
||||
|
||||
startScan: publicProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.mutation(async ({ input }) => {
|
||||
const db = getDb();
|
||||
const proj = db.select().from(projects).where(eq(projects.id, input.projectId)).get();
|
||||
if (!proj?.folderPath) throw new Error('No folder linked');
|
||||
if (proj.folderLastScanStatus === 'scanning') throw new Error('Scan already in progress');
|
||||
|
||||
// Pre-flight: walk folder to estimate indexable file count, then ask the
|
||||
// backend whether the user's tier allows proceeding.
|
||||
const delta = await scanFolder(input.projectId, proj.folderPath);
|
||||
const estimated = delta.newFiles.length + delta.changedFiles.length + delta.unchangedCount;
|
||||
|
||||
try {
|
||||
await getBackendClient().checkFolderQuota(estimated);
|
||||
} catch (err) {
|
||||
if (err instanceof QuotaError) {
|
||||
// Encode reason + backend message so the renderer can produce a
|
||||
// localised toast without an extra RPC call.
|
||||
throw new TRPCError({
|
||||
code: 'FORBIDDEN',
|
||||
message: `QUOTA:${err.reason}:${err.message}`,
|
||||
});
|
||||
}
|
||||
// Network / auth errors: propagate as-is so the renderer shows a
|
||||
// generic error toast rather than silently swallowing the problem.
|
||||
throw err;
|
||||
}
|
||||
|
||||
const session = await startIndexSession(input.projectId, (p) => {
|
||||
const entry = _active.get(input.projectId);
|
||||
if (entry) entry.lastProgress = p;
|
||||
if (
|
||||
p.status === 'completed' ||
|
||||
p.status === 'cancelled' ||
|
||||
p.status === 'quota_exceeded' ||
|
||||
p.status === 'error'
|
||||
) {
|
||||
_active.delete(input.projectId);
|
||||
}
|
||||
});
|
||||
_active.set(input.projectId, {
|
||||
cancel: session.cancel,
|
||||
lastProgress: { sessionId: session.sessionId, processed: 0, total: 0, status: 'starting' },
|
||||
});
|
||||
return { sessionId: session.sessionId };
|
||||
}),
|
||||
|
||||
cancelScan: publicProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.mutation(({ input }) => {
|
||||
const entry = _active.get(input.projectId);
|
||||
if (entry) entry.cancel();
|
||||
return { ok: true };
|
||||
}),
|
||||
|
||||
getStatus: publicProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.query(({ input }) => {
|
||||
const entry = _active.get(input.projectId);
|
||||
return entry?.lastProgress ?? null;
|
||||
}),
|
||||
|
||||
listFiles: publicProcedure
|
||||
.input(z.object({ projectId: z.string() }))
|
||||
.query(({ input }) => {
|
||||
return getDb()
|
||||
.select()
|
||||
.from(projectFolderFiles)
|
||||
.where(eq(projectFolderFiles.projectId, input.projectId))
|
||||
.orderBy(projectFolderFiles.relativePath)
|
||||
.all();
|
||||
}),
|
||||
});
|
||||
@@ -26,6 +26,8 @@ import { useTimelineHistory } from '@/hooks/useTimelineHistory';
|
||||
import type { EventSnapshot } from '@/components/timeline/history-types';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ProjectTabBar, SECTIONS, type SectionId } from './ProjectTabBar';
|
||||
import { FolderChip } from './folder/FolderChip';
|
||||
import { FilesSection } from './folder/FilesSection';
|
||||
|
||||
type ProjectDetailProps = {
|
||||
projectId: string;
|
||||
@@ -46,11 +48,13 @@ export function ProjectDetail({ projectId, initialTab }: ProjectDetailProps) {
|
||||
const timelineRef = useRef<HTMLDivElement>(null);
|
||||
const tasksRef = useRef<HTMLDivElement>(null);
|
||||
const notesRef = useRef<HTMLDivElement>(null);
|
||||
const filesRef = useRef<HTMLDivElement>(null);
|
||||
const sectionRefs: Record<SectionId, React.RefObject<HTMLDivElement | null>> = useMemo(() => ({
|
||||
overview: summaryRef,
|
||||
timeline: timelineRef,
|
||||
tasks: tasksRef,
|
||||
notes: notesRef,
|
||||
files: filesRef,
|
||||
}), []);
|
||||
|
||||
const didInitialScroll = useRef(false);
|
||||
@@ -58,6 +62,11 @@ export function ProjectDetail({ projectId, initialTab }: ProjectDetailProps) {
|
||||
const { registerSection, unregisterSection } = useFloatingChat();
|
||||
const { data: project, isLoading } = trpc.projects.get.useQuery({ id: projectId });
|
||||
|
||||
const { data: scanStatus } = trpc.projectFolders.getStatus.useQuery(
|
||||
{ projectId },
|
||||
{ refetchInterval: (query) => query.state.data?.status === 'scanning' ? 1000 : false },
|
||||
);
|
||||
|
||||
const {
|
||||
historyOpRef,
|
||||
pendingCreatePayloadRef,
|
||||
@@ -441,22 +450,56 @@ export function ProjectDetail({ projectId, initialTab }: ProjectDetailProps) {
|
||||
</Breadcrumb>
|
||||
</div>
|
||||
)}
|
||||
<h1
|
||||
className={cn(
|
||||
'font-semibold tracking-tight transition-[font-size,line-height] duration-200 ease-out',
|
||||
compact ? 'text-base leading-tight' : 'text-3xl leading-tight',
|
||||
)}
|
||||
>
|
||||
{projectName}
|
||||
{projectName && subtitle && (
|
||||
compact
|
||||
? <span className="text-muted-foreground/60 font-normal"> · </span>
|
||||
: <br />
|
||||
)}
|
||||
<span className={cn(compact ? 'text-muted-foreground/60 font-normal' : 'text-muted-foreground/50')}>
|
||||
{subtitle}
|
||||
</span>
|
||||
</h1>
|
||||
<div className="flex items-start gap-4">
|
||||
<h1
|
||||
className={cn(
|
||||
'flex-1 min-w-0 font-semibold tracking-tight transition-[font-size,line-height] duration-200 ease-out',
|
||||
compact ? 'text-base leading-tight' : 'text-3xl leading-tight',
|
||||
)}
|
||||
>
|
||||
{projectName}
|
||||
{projectName && subtitle && (
|
||||
compact
|
||||
? <span className="text-muted-foreground/60 font-normal"> · </span>
|
||||
: <br />
|
||||
)}
|
||||
<span className={cn(compact ? 'text-muted-foreground/60 font-normal' : 'text-muted-foreground/50')}>
|
||||
{subtitle}
|
||||
</span>
|
||||
</h1>
|
||||
<div
|
||||
className={cn(
|
||||
'shrink-0 overflow-hidden transition-all duration-200 ease-out',
|
||||
compact ? 'max-w-0 max-h-0 opacity-0' : 'max-w-xs max-h-8 opacity-100',
|
||||
)}
|
||||
>
|
||||
<FolderChip
|
||||
projectId={project.id}
|
||||
folderPath={project.folderPath ?? null}
|
||||
totalFiles={project.folderTotalFiles ?? 0}
|
||||
lastScannedAt={project.folderLastScannedAt ?? null}
|
||||
scanStatus={(project.folderLastScanStatus ?? 'idle') as 'idle' | 'scanning' | 'error'}
|
||||
scanProgress={scanStatus && scanStatus.status === 'scanning'
|
||||
? { processed: scanStatus.processed, total: scanStatus.total }
|
||||
: null}
|
||||
onClick={() => {
|
||||
const el = scrollRef.current;
|
||||
const ref = filesRef.current;
|
||||
if (el && ref) {
|
||||
const heroH = heroRef.current?.getBoundingClientRect().height ?? 88;
|
||||
const sectionTop = ref.getBoundingClientRect().top;
|
||||
const containerTop = el.getBoundingClientRect().top;
|
||||
const top = el.scrollTop + sectionTop - containerTop - heroH - 41;
|
||||
el.scrollTo({ top: Math.max(0, top), behavior: 'smooth' });
|
||||
}
|
||||
void navigate({
|
||||
search: (prev: Record<string, string | undefined>) => ({ ...prev, tab: 'files' }),
|
||||
replace: true,
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -613,6 +656,22 @@ export function ProjectDetail({ projectId, initialTab }: ProjectDetailProps) {
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Files section */}
|
||||
<section
|
||||
ref={filesRef}
|
||||
data-section="files"
|
||||
className="flex flex-col gap-4 pb-16"
|
||||
>
|
||||
<h1 className="text-2xl font-semibold tracking-tight">{t('projects.folder.title')}</h1>
|
||||
<FilesSection
|
||||
projectId={project.id}
|
||||
folderPath={project.folderPath ?? null}
|
||||
totalFiles={project.folderTotalFiles ?? 0}
|
||||
lastScannedAt={project.folderLastScannedAt ?? null}
|
||||
scanStatus={(project.folderLastScanStatus ?? 'idle') as 'idle' | 'scanning' | 'error'}
|
||||
/>
|
||||
</section>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -3,7 +3,7 @@ import { useNavigate } from '@tanstack/react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export const SECTIONS = ['overview', 'timeline', 'tasks', 'notes'] as const;
|
||||
export const SECTIONS = ['overview', 'timeline', 'tasks', 'notes', 'files'] as const;
|
||||
export type SectionId = typeof SECTIONS[number];
|
||||
|
||||
interface ProjectTabBarProps {
|
||||
@@ -83,6 +83,7 @@ export function ProjectTabBar({ sectionRefs, scrollRef, heroRef, initialTab }: P
|
||||
timeline: t('projects.projectTimeline'),
|
||||
tasks: t('projects.tasks'),
|
||||
notes: t('projects.notes'),
|
||||
files: t('projects.folder.title'),
|
||||
};
|
||||
|
||||
return (
|
||||
|
||||
127
src/renderer/components/projects/folder/FilesSection.tsx
Normal file
127
src/renderer/components/projects/folder/FilesSection.tsx
Normal file
@@ -0,0 +1,127 @@
|
||||
import { useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { addMonths, startOfMonth, format } from 'date-fns';
|
||||
import { Sparkles } from 'lucide-react';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Empty,
|
||||
EmptyHeader,
|
||||
EmptyMedia,
|
||||
EmptyTitle,
|
||||
EmptyDescription,
|
||||
} from '@/components/ui/empty';
|
||||
import { useNotify } from '@/hooks/useNotify';
|
||||
import { usePlatform } from '@/lib/platform';
|
||||
import { FolderLinkCard } from './FolderLinkCard';
|
||||
import { FolderFileList } from './FolderFileList';
|
||||
import { FolderUnlinkDialog } from './FolderUnlinkDialog';
|
||||
|
||||
interface FilesSectionProps {
|
||||
projectId: string;
|
||||
folderPath: string | null;
|
||||
totalFiles: number;
|
||||
lastScannedAt: number | null;
|
||||
scanStatus: 'idle' | 'scanning' | 'error' | null;
|
||||
}
|
||||
|
||||
export function FilesSection({
|
||||
projectId,
|
||||
folderPath,
|
||||
totalFiles,
|
||||
lastScannedAt,
|
||||
scanStatus,
|
||||
}: FilesSectionProps) {
|
||||
const { t } = useTranslation();
|
||||
const { notify, notifyError } = useNotify();
|
||||
const platform = usePlatform();
|
||||
const [unlinkOpen, setUnlinkOpen] = useState(false);
|
||||
const utils = trpc.useUtils();
|
||||
|
||||
const chooseFolder = trpc.projectFolders.chooseFolder.useMutation();
|
||||
const link = trpc.projectFolders.link.useMutation({
|
||||
onSuccess: () => utils.projects.get.invalidate({ id: projectId }),
|
||||
onError: (err) => notifyError('errors.error', err),
|
||||
});
|
||||
const startScan = trpc.projectFolders.startScan.useMutation();
|
||||
|
||||
/** Parse a QUOTA error message from the tRPC FORBIDDEN payload. */
|
||||
function handleScanError(err: { message?: string }): void {
|
||||
const msg = err.message ?? '';
|
||||
if (msg.startsWith('QUOTA:max_files:')) {
|
||||
// Backend message format: "Folder has X files; tier 'free' allows max Y."
|
||||
// Extract tier and max-count to pass to the i18n key.
|
||||
const detail = msg.slice('QUOTA:max_files:'.length);
|
||||
const tierMatch = detail.match(/tier '([^']+)'/);
|
||||
const countMatch = detail.match(/allows max (\d+)/);
|
||||
const tier = tierMatch?.[1] ?? 'your';
|
||||
const count = countMatch ? parseInt(countMatch[1], 10) : 0;
|
||||
notify('error', 'projects.folder.errors.tooBig', { values: { tier, count } });
|
||||
return;
|
||||
}
|
||||
if (msg.startsWith('QUOTA:monthly_tokens:')) {
|
||||
// Compute first day of next month as the reset date.
|
||||
const resetDate = format(startOfMonth(addMonths(new Date(), 1)), 'PP');
|
||||
notify('error', 'projects.folder.errors.monthlyExhausted', { values: { date: resetDate } });
|
||||
return;
|
||||
}
|
||||
notifyError('errors.error', err);
|
||||
}
|
||||
|
||||
const handleChoose = async () => {
|
||||
const chosen = await chooseFolder.mutateAsync();
|
||||
if (chosen) {
|
||||
await link.mutateAsync({ projectId, folderPath: chosen });
|
||||
// Kick first scan (fire-and-forget — progress shown via getStatus polling).
|
||||
// Quota errors are caught here so we can show localised toasts.
|
||||
startScan.mutate({ projectId }, { onError: handleScanError });
|
||||
}
|
||||
};
|
||||
|
||||
if (!platform.isElectron) {
|
||||
return (
|
||||
<div className="text-sm text-muted-foreground p-6 text-center">
|
||||
{t('projects.folder.webOnlyTooltip')}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!folderPath) {
|
||||
return (
|
||||
<Empty>
|
||||
<EmptyHeader>
|
||||
<EmptyMedia>
|
||||
<Sparkles />
|
||||
</EmptyMedia>
|
||||
<EmptyTitle>{t('projects.folder.empty.title')}</EmptyTitle>
|
||||
<EmptyDescription>{t('projects.folder.empty.description')}</EmptyDescription>
|
||||
</EmptyHeader>
|
||||
<Button
|
||||
onClick={handleChoose}
|
||||
disabled={chooseFolder.isPending || link.isPending}
|
||||
>
|
||||
{t('projects.folder.empty.cta')}
|
||||
</Button>
|
||||
</Empty>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<FolderLinkCard
|
||||
projectId={projectId}
|
||||
folderPath={folderPath}
|
||||
totalFiles={totalFiles}
|
||||
lastScannedAt={lastScannedAt}
|
||||
scanStatus={scanStatus}
|
||||
onUnlinkRequested={() => setUnlinkOpen(true)}
|
||||
/>
|
||||
<FolderFileList projectId={projectId} />
|
||||
<FolderUnlinkDialog
|
||||
projectId={projectId}
|
||||
open={unlinkOpen}
|
||||
onOpenChange={setUnlinkOpen}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
83
src/renderer/components/projects/folder/FolderChip.tsx
Normal file
83
src/renderer/components/projects/folder/FolderChip.tsx
Normal file
@@ -0,0 +1,83 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Folder, Sparkles } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
interface FolderChipProps {
|
||||
projectId: string;
|
||||
folderPath: string | null;
|
||||
totalFiles: number;
|
||||
lastScannedAt: number | null;
|
||||
scanStatus: 'idle' | 'scanning' | 'error' | null;
|
||||
scanProgress?: { processed: number; total: number } | null;
|
||||
onClick: () => void;
|
||||
}
|
||||
|
||||
export function FolderChip({
|
||||
folderPath,
|
||||
totalFiles,
|
||||
lastScannedAt,
|
||||
scanStatus,
|
||||
scanProgress,
|
||||
onClick,
|
||||
}: FolderChipProps) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (!folderPath) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium border border-dashed border-border text-muted-foreground hover:text-foreground transition-colors"
|
||||
>
|
||||
<Sparkles className="h-3 w-3" />
|
||||
{t('projects.folder.linkCta')}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (scanStatus === 'scanning' && scanProgress) {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium bg-amber-100 dark:bg-amber-900/30 text-amber-900 dark:text-amber-100"
|
||||
>
|
||||
<Folder className="h-3 w-3 animate-pulse" />
|
||||
{t('projects.folder.scanning', {
|
||||
processed: scanProgress.processed,
|
||||
total: scanProgress.total,
|
||||
})}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
if (scanStatus === 'error') {
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium bg-red-50 dark:bg-red-900/30 text-red-700 dark:text-red-200"
|
||||
>
|
||||
<Folder className="h-3 w-3" />
|
||||
{t('projects.folder.scanFailed')}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
const relative = lastScannedAt
|
||||
? formatDistanceToNow(new Date(lastScannedAt), { addSuffix: true })
|
||||
: '—';
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium',
|
||||
'bg-[#fbc881]/20 hover:bg-[#fbc881]/30 transition-colors',
|
||||
)}
|
||||
>
|
||||
<Folder className="h-3 w-3" />
|
||||
<span>{t('projects.folder.filesCount', { count: totalFiles })}</span>
|
||||
<span className="opacity-60">·</span>
|
||||
<span className="opacity-70">{relative}</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
70
src/renderer/components/projects/folder/FolderFileList.tsx
Normal file
70
src/renderer/components/projects/folder/FolderFileList.tsx
Normal file
@@ -0,0 +1,70 @@
|
||||
import { useState, useMemo } from 'react';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { Skeleton } from '@/components/ui/skeleton';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
interface FolderFileListProps {
|
||||
projectId: string;
|
||||
}
|
||||
|
||||
type Filter = 'all' | 'text' | 'image' | 'pdf' | 'docx';
|
||||
|
||||
const FILTERS: Filter[] = ['all', 'text', 'image', 'pdf', 'docx'];
|
||||
|
||||
export function FolderFileList({ projectId }: FolderFileListProps) {
|
||||
const [filter, setFilter] = useState<Filter>('all');
|
||||
const { data, isLoading } = trpc.projectFolders.listFiles.useQuery({ projectId });
|
||||
|
||||
const items = useMemo(() => {
|
||||
if (!data) return [];
|
||||
if (filter === 'all') return data;
|
||||
return data.filter((f) => f.kind === filter);
|
||||
}, [data, filter]);
|
||||
|
||||
if (isLoading) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
{[0, 1, 2].map((i) => (
|
||||
<Skeleton key={i} className="h-12" />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="flex gap-2 mb-3 text-xs">
|
||||
{FILTERS.map((f) => (
|
||||
<button
|
||||
key={f}
|
||||
onClick={() => setFilter(f)}
|
||||
className={cn(
|
||||
'px-2.5 py-1 rounded-full border border-border',
|
||||
filter === f
|
||||
? 'bg-foreground text-background'
|
||||
: 'text-muted-foreground hover:text-foreground',
|
||||
)}
|
||||
>
|
||||
{f}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<ul className="space-y-1.5">
|
||||
{items.map((f) => (
|
||||
<li
|
||||
key={f.id}
|
||||
className={cn(
|
||||
'rounded-md px-3 py-2 border border-border bg-background/50',
|
||||
f.kind === 'skipped' && 'opacity-50',
|
||||
)}
|
||||
>
|
||||
<div className="font-mono text-xs">{f.relativePath}</div>
|
||||
{f.summary && (
|
||||
<div className="text-xs text-muted-foreground mt-0.5 line-clamp-2">{f.summary}</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
65
src/renderer/components/projects/folder/FolderLinkCard.tsx
Normal file
65
src/renderer/components/projects/folder/FolderLinkCard.tsx
Normal file
@@ -0,0 +1,65 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Folder } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { useNotify } from '@/hooks/useNotify';
|
||||
import { formatDistanceToNow } from 'date-fns';
|
||||
|
||||
interface FolderLinkCardProps {
|
||||
projectId: string;
|
||||
folderPath: string;
|
||||
totalFiles: number;
|
||||
lastScannedAt: number | null;
|
||||
scanStatus: 'idle' | 'scanning' | 'error' | null;
|
||||
onUnlinkRequested: () => void;
|
||||
}
|
||||
|
||||
export function FolderLinkCard({
|
||||
projectId,
|
||||
folderPath,
|
||||
totalFiles,
|
||||
lastScannedAt,
|
||||
scanStatus,
|
||||
onUnlinkRequested,
|
||||
}: FolderLinkCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const { notifyError } = useNotify();
|
||||
const utils = trpc.useUtils();
|
||||
const startScan = trpc.projectFolders.startScan.useMutation({
|
||||
onSuccess: () => utils.projects.get.invalidate({ id: projectId }),
|
||||
onError: (err) => notifyError('errors.error', err),
|
||||
});
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-3">
|
||||
<Folder className="h-4 w-4 text-muted-foreground shrink-0" />
|
||||
<div className="flex-1 min-w-0">
|
||||
<div className="font-mono text-xs text-muted-foreground truncate">{folderPath}</div>
|
||||
<div className="text-xs text-muted-foreground/70 mt-0.5">
|
||||
{t('projects.folder.filesCount', { count: totalFiles })}
|
||||
{lastScannedAt && (
|
||||
<>
|
||||
{' · '}
|
||||
{t('projects.folder.lastScanned', {
|
||||
relative: formatDistanceToNow(new Date(lastScannedAt), { addSuffix: true }),
|
||||
})}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 shrink-0">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={scanStatus === 'scanning' || startScan.isPending}
|
||||
onClick={() => startScan.mutate({ projectId })}
|
||||
>
|
||||
{t('projects.folder.rescan')}
|
||||
</Button>
|
||||
<Button variant="ghost" size="sm" onClick={onUnlinkRequested}>
|
||||
{t('projects.folder.unlink')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { useNotify } from '@/hooks/useNotify';
|
||||
|
||||
interface FolderUnlinkDialogProps {
|
||||
projectId: string;
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
}
|
||||
|
||||
export function FolderUnlinkDialog({
|
||||
projectId,
|
||||
open,
|
||||
onOpenChange,
|
||||
}: FolderUnlinkDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const { notifyError } = useNotify();
|
||||
const utils = trpc.useUtils();
|
||||
const unlink = trpc.projectFolders.unlink.useMutation({
|
||||
onSuccess: () => {
|
||||
utils.projects.get.invalidate({ id: projectId });
|
||||
utils.projectFolders.listFiles.invalidate({ projectId });
|
||||
onOpenChange(false);
|
||||
},
|
||||
onError: (err) => notifyError('errors.error', err),
|
||||
});
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('projects.folder.unlink')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{t('projects.deleteProjectDescription')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="ghost" onClick={() => onOpenChange(false)}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
onClick={() => unlink.mutate({ projectId })}
|
||||
disabled={unlink.isPending}
|
||||
>
|
||||
{unlink.isPending ? t('common.deleting') : t('projects.folder.unlink')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +1,29 @@
|
||||
import { forwardRef } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export function PropertyPill({
|
||||
icon,
|
||||
label,
|
||||
value,
|
||||
empty,
|
||||
onClick,
|
||||
}: {
|
||||
export interface PropertyPillProps
|
||||
extends Omit<React.ButtonHTMLAttributes<HTMLButtonElement>, 'children'> {
|
||||
icon: React.ReactNode;
|
||||
label: string;
|
||||
value?: React.ReactNode;
|
||||
value?: string | null;
|
||||
empty?: boolean;
|
||||
onClick?: () => void;
|
||||
}) {
|
||||
return (
|
||||
}
|
||||
|
||||
export const PropertyPill = forwardRef<HTMLButtonElement, PropertyPillProps>(
|
||||
({ icon, label, value, empty, className, ...rest }, ref) => (
|
||||
<button
|
||||
ref={ref}
|
||||
type="button"
|
||||
onClick={onClick}
|
||||
data-empty={empty ? 'true' : undefined}
|
||||
className={cn(
|
||||
'inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-xs transition-colors',
|
||||
'focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring/30 focus-visible:border-ring',
|
||||
empty
|
||||
? 'border border-dashed border-border text-muted-foreground hover:text-foreground hover:border-foreground/50'
|
||||
: 'border border-border/60 bg-background/60 text-foreground hover:border-ring/40',
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
<span className="flex items-center">{icon}</span>
|
||||
{empty ? (
|
||||
@@ -34,5 +35,6 @@ export function PropertyPill({
|
||||
</>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
),
|
||||
);
|
||||
PropertyPill.displayName = 'PropertyPill';
|
||||
|
||||
@@ -1,62 +1,24 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { TZDate } from 'react-day-picker';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Folder, ArrowUp, ArrowRight, ArrowDown, Circle, Clock, CheckCircle2, Calendar as CalIcon, UserPlus, Plus, Paperclip } from 'lucide-react';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { PropertyPill } from './PropertyPill';
|
||||
import { InlineProjectForm } from './InlineProjectForm';
|
||||
import { useFormatPrefs, formatDueDate } from '@/lib/date';
|
||||
import { useTaskAttachments } from './useTaskAttachments';
|
||||
|
||||
const HOURS = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'));
|
||||
const MINUTES = ['00', '05', '10', '15', '20', '25', '30', '35', '40', '45', '50', '55'];
|
||||
|
||||
/**
|
||||
* Decode a ms-epoch timestamp into Y/M/D H/M parts as observed in the given IANA timezone.
|
||||
* Returns null when ms is null/undefined.
|
||||
*/
|
||||
function derivePartsInTz(
|
||||
ms: number | null | undefined,
|
||||
timezone: string,
|
||||
): { year: number; month: number; day: number; hour: number; minute: number } | null {
|
||||
if (ms == null) return null;
|
||||
const fmt = new Intl.DateTimeFormat('en-CA', {
|
||||
timeZone: timezone,
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
hour12: false,
|
||||
});
|
||||
const parts = fmt.formatToParts(new Date(ms));
|
||||
const get = (type: string) => parseInt(parts.find((p) => p.type === type)?.value ?? '0', 10);
|
||||
return {
|
||||
year: get('year'),
|
||||
month: get('month'),
|
||||
day: get('day'),
|
||||
// 24h Intl formatters in some locales render midnight as "24" — normalize to 0.
|
||||
hour: get('hour') % 24,
|
||||
minute: get('minute'),
|
||||
};
|
||||
}
|
||||
import { useRovingFocus } from '@/hooks/useRovingFocus';
|
||||
import { useListboxKeys } from '@/hooks/useListboxKeys';
|
||||
import { DateTimeField } from '@/components/ui/datetime-field';
|
||||
|
||||
export type TaskFormValues = {
|
||||
title: string;
|
||||
@@ -90,6 +52,280 @@ const DEFAULTS: TaskFormValues = {
|
||||
estimate: null,
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Sub-components (defined outside TaskFormDialog to avoid recreation on render)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function ProjectList({
|
||||
projects,
|
||||
selectedId,
|
||||
onSelect,
|
||||
onCreate,
|
||||
onClose,
|
||||
}: {
|
||||
projects: { id: string; name: string }[];
|
||||
selectedId: string | null;
|
||||
onSelect: (id: string | null) => void;
|
||||
onCreate: () => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
// Item layout: [0] "+ New project", [1] "No project", [2..N+1] projects
|
||||
const items = [
|
||||
{ kind: 'new' as const },
|
||||
{ kind: 'none' as const },
|
||||
...projects.map((p) => ({ kind: 'project' as const, id: p.id, name: p.name })),
|
||||
];
|
||||
const listbox = useListboxKeys({
|
||||
itemCount: items.length,
|
||||
initialIndex: selectedId
|
||||
? Math.max(0, items.findIndex((it) => it.kind === 'project' && it.id === selectedId))
|
||||
: 1,
|
||||
onSelect: (i) => {
|
||||
const it = items[i];
|
||||
if (it.kind === 'new') onCreate();
|
||||
else if (it.kind === 'none') onSelect(null);
|
||||
else onSelect(it.id);
|
||||
},
|
||||
onClose,
|
||||
});
|
||||
|
||||
useEffect(() => {
|
||||
listbox.focusIndex(listbox.activeIndex);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const base =
|
||||
'w-full text-left px-2 py-1.5 text-sm rounded hover:bg-accent/50 focus:bg-accent/60 focus:outline-none flex items-center gap-1.5';
|
||||
|
||||
return (
|
||||
<div className="p-1" role="listbox" aria-label={t('tasks.project')}>
|
||||
{items.map((it, i) => {
|
||||
const itemProps = listbox.getItemProps(i);
|
||||
if (it.kind === 'new') {
|
||||
return (
|
||||
<button
|
||||
key="new"
|
||||
type="button"
|
||||
{...itemProps}
|
||||
role="option"
|
||||
className={base + ' text-primary'}
|
||||
onClick={() => onCreate()}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t('projects.newProject')}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
if (it.kind === 'none') {
|
||||
return (
|
||||
<button
|
||||
key="none"
|
||||
type="button"
|
||||
{...itemProps}
|
||||
role="option"
|
||||
className={base}
|
||||
onClick={() => onSelect(null)}
|
||||
>
|
||||
{t('tasks.noProject')}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<button
|
||||
key={it.id}
|
||||
type="button"
|
||||
{...itemProps}
|
||||
role="option"
|
||||
className={base}
|
||||
onClick={() => onSelect(it.id)}
|
||||
>
|
||||
{it.name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function PriorityList({
|
||||
value,
|
||||
onSelect,
|
||||
onClose,
|
||||
}: {
|
||||
value: string;
|
||||
onSelect: (v: 'high' | 'medium' | 'low') => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const items = ['high', 'medium', 'low'] as const;
|
||||
const initial = Math.max(0, items.indexOf(value as (typeof items)[number]));
|
||||
const listbox = useListboxKeys({
|
||||
itemCount: items.length,
|
||||
initialIndex: initial,
|
||||
onSelect: (i) => onSelect(items[i]),
|
||||
onClose,
|
||||
});
|
||||
useEffect(() => {
|
||||
listbox.focusIndex(listbox.activeIndex);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
return (
|
||||
<div role="listbox" aria-label={t('tasks.priority')} className="p-1">
|
||||
{items.map((p, i) => (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
{...listbox.getItemProps(i)}
|
||||
role="option"
|
||||
aria-selected={value === p}
|
||||
onClick={() => onSelect(p)}
|
||||
className="w-full text-left px-2 py-1.5 text-sm rounded hover:bg-accent/50 focus:bg-accent/60 focus:outline-none"
|
||||
>
|
||||
{t(`tasks.${p}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatusList({
|
||||
value,
|
||||
onSelect,
|
||||
onClose,
|
||||
}: {
|
||||
value: string;
|
||||
onSelect: (v: 'todo' | 'in_progress' | 'done') => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const items = ['todo', 'in_progress', 'done'] as const;
|
||||
const initial = Math.max(0, items.indexOf(value as (typeof items)[number]));
|
||||
const listbox = useListboxKeys({
|
||||
itemCount: items.length,
|
||||
initialIndex: initial,
|
||||
onSelect: (i) => onSelect(items[i]),
|
||||
onClose,
|
||||
});
|
||||
useEffect(() => {
|
||||
listbox.focusIndex(listbox.activeIndex);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
return (
|
||||
<div role="listbox" aria-label={t('tasks.status')} className="p-1">
|
||||
{items.map((s, i) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
{...listbox.getItemProps(i)}
|
||||
role="option"
|
||||
aria-selected={value === s}
|
||||
onClick={() => onSelect(s)}
|
||||
className="w-full text-left px-2 py-1.5 text-sm rounded hover:bg-accent/50 focus:bg-accent/60 focus:outline-none"
|
||||
>
|
||||
{t(s === 'todo' ? 'tasks.toDo' : s === 'in_progress' ? 'tasks.inProgress' : 'tasks.done')}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function AssigneesList({
|
||||
known,
|
||||
selected,
|
||||
onToggle,
|
||||
onClose,
|
||||
newName,
|
||||
onNewNameChange,
|
||||
onAddNew,
|
||||
}: {
|
||||
known: string[];
|
||||
selected: string[];
|
||||
onToggle: (name: string) => void;
|
||||
onClose: () => void;
|
||||
newName: string;
|
||||
onNewNameChange: (s: string) => void;
|
||||
onAddNew: () => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
const itemRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
||||
const listbox = useListboxKeys({
|
||||
itemCount: known.length,
|
||||
initialIndex: 0,
|
||||
onSelect: (i) => onToggle(known[i]),
|
||||
onClose,
|
||||
});
|
||||
useEffect(() => {
|
||||
if (known.length > 0) listbox.focusIndex(0);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
return (
|
||||
<div className="p-2" role="listbox" aria-multiselectable="true" aria-label={t('tasks.assignees')}>
|
||||
<div className="flex flex-col gap-1 max-h-40 overflow-y-auto">
|
||||
{known.map((name, i) => {
|
||||
const isOn = selected.includes(name);
|
||||
const itemProps = listbox.getItemProps(i);
|
||||
return (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
{...itemProps}
|
||||
ref={(el) => {
|
||||
itemRefs.current[i] = el;
|
||||
itemProps.ref(el);
|
||||
}}
|
||||
role="option"
|
||||
aria-selected={isOn}
|
||||
onClick={() => onToggle(name)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'ArrowDown' && i === known.length - 1) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
inputRef.current?.focus();
|
||||
return;
|
||||
}
|
||||
itemProps.onKeyDown(e);
|
||||
}}
|
||||
className="text-left px-2 py-1.5 text-sm rounded hover:bg-accent/50 focus:bg-accent/60 focus:outline-none"
|
||||
>
|
||||
{isOn ? '✓ ' : ' '}{name}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div className="border-t mt-2 pt-2 flex gap-1.5">
|
||||
<Input
|
||||
ref={inputRef}
|
||||
placeholder={t('tasks.newAssigneeName', 'New name…')}
|
||||
value={newName}
|
||||
onChange={(e) => onNewNameChange(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
onAddNew();
|
||||
} else if (e.key === 'ArrowUp' && known.length > 0) {
|
||||
e.preventDefault();
|
||||
itemRefs.current[known.length - 1]?.focus();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
onClose();
|
||||
}
|
||||
}}
|
||||
className="h-8 text-sm flex-1"
|
||||
/>
|
||||
<Button type="button" size="sm" onClick={onAddNew} disabled={!newName.trim()}>
|
||||
{t('common.add')}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Main component
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export function TaskFormDialog({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -102,15 +338,26 @@ export function TaskFormDialog({
|
||||
const { t } = useTranslation();
|
||||
const [values, setValues] = useState<TaskFormValues>({ ...DEFAULTS, ...initialValues });
|
||||
const [projectPopoverOpen, setProjectPopoverOpen] = useState(false);
|
||||
const [priorityOpen, setPriorityOpen] = useState(false);
|
||||
const [statusOpen, setStatusOpen] = useState(false);
|
||||
const [assigneesOpen, setAssigneesOpen] = useState(false);
|
||||
const [dueOpen, setDueOpen] = useState(false);
|
||||
const [creatingProject, setCreatingProject] = useState(false);
|
||||
const [assigneeInput, setAssigneeInput] = useState('');
|
||||
|
||||
const PILL_COUNT = 5; // Project, Priority, Status, Due, Assignees
|
||||
const pillsRoving = useRovingFocus({ count: PILL_COUNT, direction: 'both' });
|
||||
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
setValues({ ...DEFAULTS, ...initialValues });
|
||||
setCreatingProject(false);
|
||||
setAssigneeInput('');
|
||||
setProjectPopoverOpen(false);
|
||||
setPriorityOpen(false);
|
||||
setStatusOpen(false);
|
||||
setAssigneesOpen(false);
|
||||
setDueOpen(false);
|
||||
}
|
||||
}, [open, initialValues]);
|
||||
|
||||
@@ -121,36 +368,19 @@ export function TaskFormDialog({
|
||||
setAssigneeInput('');
|
||||
}
|
||||
|
||||
const handleDueChange = useCallback((d: Date | undefined) => {
|
||||
setValues((v) => ({ ...v, dueDate: d ? d.getTime() : null }));
|
||||
}, []);
|
||||
const handleDueCommit = useCallback(() => {
|
||||
setDueOpen(false);
|
||||
}, []);
|
||||
|
||||
const { data: projectsList = [] } = trpc.projects.listAll.useQuery();
|
||||
const { data: knownAssignees = [] } = trpc.tasks.listAssignees.useQuery();
|
||||
const prefs = useFormatPrefs();
|
||||
const selectedProject = projectsList.find((p) => p.id === values.projectId);
|
||||
const attachments = useTaskAttachments(mode === 'edit' && taskId ? taskId : null);
|
||||
|
||||
// Derive H/M from current dueDate in user timezone for display in time selects.
|
||||
const parts = derivePartsInTz(values.dueDate, prefs.timezone);
|
||||
const dueHour = parts ? String(parts.hour).padStart(2, '0') : '';
|
||||
// Snap minute display to nearest 5-minute step value if in selectable list.
|
||||
const minuteStr = parts ? String(parts.minute).padStart(2, '0') : '';
|
||||
const dueMinute = MINUTES.includes(minuteStr) ? minuteStr : '';
|
||||
|
||||
function updateDueTime(hour: string, minute: string) {
|
||||
if (!values.dueDate) return;
|
||||
const cur = derivePartsInTz(values.dueDate, prefs.timezone);
|
||||
if (!cur) return;
|
||||
const tz = new TZDate(
|
||||
cur.year,
|
||||
cur.month - 1,
|
||||
cur.day,
|
||||
parseInt(hour, 10),
|
||||
parseInt(minute, 10),
|
||||
0,
|
||||
0,
|
||||
prefs.timezone,
|
||||
);
|
||||
setValues((v) => ({ ...v, dueDate: tz.getTime() }));
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!values.title.trim()) return;
|
||||
@@ -160,10 +390,13 @@ export function TaskFormDialog({
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[580px] p-0 gap-0 overflow-hidden bg-card/92 backdrop-blur-xl">
|
||||
<DialogHeader className="px-5 py-3 border-b border-border/40">
|
||||
<DialogTitle className="text-sm font-medium">
|
||||
<DialogHeader className="px-5 pt-5 pb-2">
|
||||
<DialogTitle>
|
||||
{mode === 'create' ? t('tasks.newTask') : t('tasks.editTask')}
|
||||
</DialogTitle>
|
||||
<DialogDescription>
|
||||
{mode === 'create' ? t('tasks.newTaskDescription') : t('tasks.editTaskDescription')}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<form
|
||||
@@ -189,12 +422,16 @@ export function TaskFormDialog({
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* PROPERTIES section + footer go in next tasks */}
|
||||
<div className="px-5 pb-3 pt-1">
|
||||
<div className="text-[10px] uppercase tracking-wider text-muted-foreground mb-2">
|
||||
{t('tasks.properties')}
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-1.5" data-testid="property-pills">
|
||||
<div
|
||||
className="flex flex-wrap gap-1.5"
|
||||
data-testid="property-pills"
|
||||
role="toolbar"
|
||||
aria-label={t('tasks.properties')}
|
||||
>
|
||||
{/* Project */}
|
||||
<Popover
|
||||
open={projectPopoverOpen}
|
||||
@@ -204,17 +441,17 @@ export function TaskFormDialog({
|
||||
}}
|
||||
>
|
||||
<PopoverTrigger asChild>
|
||||
<span>
|
||||
<PropertyPill
|
||||
icon={<Folder className="h-3 w-3" />}
|
||||
label={t('tasks.project')}
|
||||
value={selectedProject?.name ?? null}
|
||||
empty={!selectedProject}
|
||||
/>
|
||||
</span>
|
||||
<PropertyPill
|
||||
{...pillsRoving.getItemProps(0)}
|
||||
icon={<Folder className="h-3 w-3" />}
|
||||
label={t('tasks.project')}
|
||||
value={selectedProject?.name ?? null}
|
||||
empty={!selectedProject}
|
||||
aria-label={t('tasks.project') + (selectedProject ? `: ${selectedProject.name}` : '')}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-72 p-0 max-h-96 overflow-y-auto" align="start">
|
||||
{creatingProject ? (
|
||||
{projectPopoverOpen && (creatingProject ? (
|
||||
<InlineProjectForm
|
||||
onCancel={() => setCreatingProject(false)}
|
||||
onCreated={(id) => {
|
||||
@@ -224,243 +461,132 @@ export function TaskFormDialog({
|
||||
}}
|
||||
/>
|
||||
) : (
|
||||
<div className="p-1">
|
||||
<button
|
||||
type="button"
|
||||
className="w-full text-left px-2 py-1.5 text-sm rounded hover:bg-accent/50 flex items-center gap-1.5 text-primary"
|
||||
onClick={() => setCreatingProject(true)}
|
||||
>
|
||||
<Plus className="h-3.5 w-3.5" />
|
||||
{t('projects.newProject')}
|
||||
</button>
|
||||
<div className="my-1 h-px bg-border/60" />
|
||||
<button
|
||||
type="button"
|
||||
className="w-full text-left px-2 py-1.5 text-sm rounded hover:bg-accent/50"
|
||||
onClick={() => {
|
||||
setValues((v) => ({ ...v, projectId: null }));
|
||||
setProjectPopoverOpen(false);
|
||||
}}
|
||||
>
|
||||
{t('tasks.noProject')}
|
||||
</button>
|
||||
{projectsList.map((p) => (
|
||||
<button
|
||||
key={p.id}
|
||||
type="button"
|
||||
className="w-full text-left px-2 py-1.5 text-sm rounded hover:bg-accent/50"
|
||||
onClick={() => {
|
||||
setValues((v) => ({ ...v, projectId: p.id }));
|
||||
setProjectPopoverOpen(false);
|
||||
}}
|
||||
>
|
||||
{p.name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<ProjectList
|
||||
projects={projectsList}
|
||||
selectedId={values.projectId}
|
||||
onSelect={(id) => {
|
||||
setValues((v) => ({ ...v, projectId: id }));
|
||||
setProjectPopoverOpen(false);
|
||||
}}
|
||||
onCreate={() => setCreatingProject(true)}
|
||||
onClose={() => setProjectPopoverOpen(false)}
|
||||
/>
|
||||
))}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Priority */}
|
||||
<Popover>
|
||||
<Popover open={priorityOpen} onOpenChange={setPriorityOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<span>
|
||||
<PropertyPill
|
||||
icon={
|
||||
values.priority === 'high' ? <ArrowUp className="h-3 w-3 text-red-600" /> :
|
||||
values.priority === 'low' ? <ArrowDown className="h-3 w-3 text-muted-foreground" /> :
|
||||
<ArrowRight className="h-3 w-3 text-amber-600" />
|
||||
}
|
||||
label={t('tasks.priority')}
|
||||
value={t(`tasks.${values.priority}`)}
|
||||
/>
|
||||
</span>
|
||||
<PropertyPill
|
||||
{...pillsRoving.getItemProps(1)}
|
||||
icon={
|
||||
values.priority === 'high' ? <ArrowUp className="h-3 w-3 text-red-600" /> :
|
||||
values.priority === 'low' ? <ArrowDown className="h-3 w-3 text-muted-foreground" /> :
|
||||
<ArrowRight className="h-3 w-3 text-amber-600" />
|
||||
}
|
||||
label={t('tasks.priority')}
|
||||
value={t(`tasks.${values.priority}`)}
|
||||
aria-label={t('tasks.priority') + `: ${t(`tasks.${values.priority}`)}`}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-40 p-1" align="start">
|
||||
{(['high', 'medium', 'low'] as const).map((p) => (
|
||||
<button
|
||||
key={p}
|
||||
type="button"
|
||||
onClick={() => setValues((v) => ({ ...v, priority: p }))}
|
||||
className="w-full text-left px-2 py-1.5 text-sm rounded hover:bg-accent/50"
|
||||
>
|
||||
{t(`tasks.${p}`)}
|
||||
</button>
|
||||
))}
|
||||
<PopoverContent className="w-40 p-0" align="start">
|
||||
{priorityOpen && (
|
||||
<PriorityList
|
||||
value={values.priority}
|
||||
onSelect={(p) => {
|
||||
setValues((v) => ({ ...v, priority: p }));
|
||||
setPriorityOpen(false);
|
||||
}}
|
||||
onClose={() => setPriorityOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Status */}
|
||||
<Popover>
|
||||
<Popover open={statusOpen} onOpenChange={setStatusOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<span>
|
||||
<PropertyPill
|
||||
icon={
|
||||
values.status === 'in_progress' ? <Clock className="h-3 w-3" /> :
|
||||
values.status === 'done' ? <CheckCircle2 className="h-3 w-3" /> :
|
||||
<Circle className="h-3 w-3" />
|
||||
}
|
||||
label={t('tasks.status')}
|
||||
value={t(values.status === 'todo' ? 'tasks.toDo' : values.status === 'in_progress' ? 'tasks.inProgress' : 'tasks.done')}
|
||||
/>
|
||||
</span>
|
||||
<PropertyPill
|
||||
{...pillsRoving.getItemProps(2)}
|
||||
icon={
|
||||
values.status === 'in_progress' ? <Clock className="h-3 w-3" /> :
|
||||
values.status === 'done' ? <CheckCircle2 className="h-3 w-3" /> :
|
||||
<Circle className="h-3 w-3" />
|
||||
}
|
||||
label={t('tasks.status')}
|
||||
value={t(values.status === 'todo' ? 'tasks.toDo' : values.status === 'in_progress' ? 'tasks.inProgress' : 'tasks.done')}
|
||||
aria-label={t('tasks.status') + `: ${t(values.status === 'todo' ? 'tasks.toDo' : values.status === 'in_progress' ? 'tasks.inProgress' : 'tasks.done')}`}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-40 p-1" align="start">
|
||||
{(['todo', 'in_progress', 'done'] as const).map((s) => (
|
||||
<button
|
||||
key={s}
|
||||
type="button"
|
||||
onClick={() => setValues((v) => ({ ...v, status: s }))}
|
||||
className="w-full text-left px-2 py-1.5 text-sm rounded hover:bg-accent/50"
|
||||
>
|
||||
{t(s === 'todo' ? 'tasks.toDo' : s === 'in_progress' ? 'tasks.inProgress' : 'tasks.done')}
|
||||
</button>
|
||||
))}
|
||||
<PopoverContent className="w-40 p-0" align="start">
|
||||
{statusOpen && (
|
||||
<StatusList
|
||||
value={values.status}
|
||||
onSelect={(s) => {
|
||||
setValues((v) => ({ ...v, status: s }));
|
||||
setStatusOpen(false);
|
||||
}}
|
||||
onClose={() => setStatusOpen(false)}
|
||||
/>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Due date */}
|
||||
<Popover>
|
||||
<Popover open={dueOpen} onOpenChange={setDueOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<span>
|
||||
<PropertyPill
|
||||
icon={<CalIcon className="h-3 w-3" />}
|
||||
label={t('tasks.colDue')}
|
||||
value={values.dueDate ? formatDueDate(values.dueDate, prefs) : null}
|
||||
empty={!values.dueDate}
|
||||
/>
|
||||
</span>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={values.dueDate ? new Date(values.dueDate) : undefined}
|
||||
timeZone={prefs.timezone}
|
||||
onSelect={(d) => {
|
||||
if (!d) {
|
||||
setValues((v) => ({ ...v, dueDate: null }));
|
||||
return;
|
||||
}
|
||||
// Preserve current H/M if any; otherwise default to 00:00 in user tz.
|
||||
const cur = derivePartsInTz(values.dueDate, prefs.timezone);
|
||||
const tz = new TZDate(
|
||||
d.getFullYear(),
|
||||
d.getMonth(),
|
||||
d.getDate(),
|
||||
cur?.hour ?? 0,
|
||||
cur?.minute ?? 0,
|
||||
0,
|
||||
0,
|
||||
prefs.timezone,
|
||||
);
|
||||
setValues((v) => ({ ...v, dueDate: tz.getTime() }));
|
||||
}}
|
||||
<PropertyPill
|
||||
{...pillsRoving.getItemProps(3)}
|
||||
icon={<CalIcon className="h-3 w-3" />}
|
||||
label={t('tasks.colDue')}
|
||||
value={values.dueDate ? formatDueDate(values.dueDate, prefs) : null}
|
||||
empty={!values.dueDate}
|
||||
aria-label={t('tasks.colDue')}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-3" align="start">
|
||||
<DateTimeField
|
||||
withTime
|
||||
value={values.dueDate ? new Date(values.dueDate) : undefined}
|
||||
onChange={handleDueChange}
|
||||
onCommit={handleDueCommit}
|
||||
aria-label={t('tasks.colDue')}
|
||||
/>
|
||||
<div className="border-t px-3 py-2 flex flex-col gap-2">
|
||||
<div>
|
||||
<label className="text-xs text-muted-foreground mb-1 block">
|
||||
{t('tasks.timeOptional')}
|
||||
</label>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<Select
|
||||
value={dueHour}
|
||||
onValueChange={(h) => updateDueTime(h, dueMinute || '00')}
|
||||
disabled={!values.dueDate}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-20 text-sm">
|
||||
<SelectValue placeholder="HH" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{HOURS.map((h) => (
|
||||
<SelectItem key={h} value={h}>{h}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-muted-foreground text-sm">:</span>
|
||||
<Select
|
||||
value={dueMinute}
|
||||
onValueChange={(m) => updateDueTime(dueHour || '00', m)}
|
||||
disabled={!values.dueDate}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-20 text-sm">
|
||||
<SelectValue placeholder="MM" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MINUTES.map((m) => (
|
||||
<SelectItem key={m} value={m}>{m}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{values.dueDate && (dueHour !== '00' || dueMinute !== '00') && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 px-2 text-xs"
|
||||
onClick={() => updateDueTime('00', '00')}
|
||||
>
|
||||
{t('common.clear')}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
|
||||
{/* Assignees */}
|
||||
<Popover>
|
||||
<Popover open={assigneesOpen} onOpenChange={setAssigneesOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<span>
|
||||
<PropertyPill
|
||||
icon={<UserPlus className="h-3 w-3" />}
|
||||
label={t('tasks.assignees')}
|
||||
value={values.assignees.length > 0 ? values.assignees.join(', ') : null}
|
||||
empty={values.assignees.length === 0}
|
||||
/>
|
||||
</span>
|
||||
<PropertyPill
|
||||
{...pillsRoving.getItemProps(4)}
|
||||
icon={<UserPlus className="h-3 w-3" />}
|
||||
label={t('tasks.assignees')}
|
||||
value={values.assignees.length > 0 ? values.assignees.join(', ') : null}
|
||||
empty={values.assignees.length === 0}
|
||||
aria-label={t('tasks.assignees') + (values.assignees.length > 0 ? `: ${values.assignees.join(', ')}` : '')}
|
||||
/>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-64 p-2" align="start">
|
||||
<div className="flex flex-col gap-1 max-h-40 overflow-y-auto">
|
||||
{knownAssignees.map((name) => (
|
||||
<button
|
||||
key={name}
|
||||
type="button"
|
||||
onClick={() => setValues((v) => ({
|
||||
<PopoverContent className="w-64 p-0" align="start">
|
||||
{assigneesOpen && (
|
||||
<AssigneesList
|
||||
known={knownAssignees}
|
||||
selected={values.assignees}
|
||||
onToggle={(name) =>
|
||||
setValues((v) => ({
|
||||
...v,
|
||||
assignees: v.assignees.includes(name)
|
||||
? v.assignees.filter((a) => a !== name)
|
||||
: [...v.assignees, name],
|
||||
}))}
|
||||
className="text-left px-2 py-1.5 text-sm rounded hover:bg-accent/50"
|
||||
>
|
||||
{values.assignees.includes(name) ? '✓ ' : ' '}{name}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<div className="border-t mt-2 pt-2 flex gap-1.5">
|
||||
<Input
|
||||
placeholder={t('tasks.newAssigneeName', 'New name…')}
|
||||
value={assigneeInput}
|
||||
onChange={(e) => setAssigneeInput(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
addNewAssignee();
|
||||
}
|
||||
}}
|
||||
className="h-8 text-sm flex-1"
|
||||
}))
|
||||
}
|
||||
onClose={() => setAssigneesOpen(false)}
|
||||
newName={assigneeInput}
|
||||
onNewNameChange={setAssigneeInput}
|
||||
onAddNew={addNewAssignee}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
size="sm"
|
||||
onClick={addNewAssignee}
|
||||
disabled={!assigneeInput.trim()}
|
||||
>
|
||||
{t('common.add')}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
</div>
|
||||
|
||||
@@ -1,14 +1,14 @@
|
||||
import { useState } from 'react';
|
||||
import { useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useFormatPrefs, formatDate } from '@/lib/date';
|
||||
import { CalendarIcon, Check } from 'lucide-react';
|
||||
import { type DateRange } from 'react-day-picker';
|
||||
import { Check, X } from 'lucide-react';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { useNotify } from '@/hooks/useNotify';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
@@ -21,10 +21,10 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import { ScrollArea } from '@/components/ui/scroll-area';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
||||
import { DateField } from '@/components/ui/date-field';
|
||||
import { cn } from '@/lib/utils';
|
||||
import type { TimelineEventType } from './ProjectTimeline';
|
||||
import type { HistoryEntry } from './history-types';
|
||||
|
||||
@@ -35,218 +35,414 @@ interface AddEventDialogProps {
|
||||
onRecordHistory?: (entry: HistoryEntry) => void;
|
||||
}
|
||||
|
||||
interface AddedEntry {
|
||||
type StagedEvent = {
|
||||
id: string;
|
||||
title: string;
|
||||
type: TimelineEventType;
|
||||
date: Date;
|
||||
endDate?: Date;
|
||||
type: TimelineEventType;
|
||||
};
|
||||
|
||||
type Mode = { kind: 'add' } | { kind: 'edit'; id: string };
|
||||
|
||||
function newLocalId(): string {
|
||||
return 'staged_' + Math.random().toString(36).slice(2, 10);
|
||||
}
|
||||
|
||||
export function AddEventDialog({ open, onOpenChange, defaultProjectId, onRecordHistory }: AddEventDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const prefs = useFormatPrefs();
|
||||
const { notify, notifyError } = useNotify();
|
||||
|
||||
const [projectId, setProjectId] = useState(defaultProjectId ?? '');
|
||||
const [staged, setStaged] = useState<StagedEvent[]>([]);
|
||||
const [mode, setMode] = useState<Mode>({ kind: 'add' });
|
||||
|
||||
const [title, setTitle] = useState('');
|
||||
const [type, setType] = useState<TimelineEventType>('milestone');
|
||||
const [dateRange, setDateRange] = useState<DateRange | undefined>();
|
||||
const [singleDate, setSingleDate] = useState<Date | undefined>();
|
||||
const [projectId, setProjectId] = useState(defaultProjectId ?? '');
|
||||
const [added, setAdded] = useState<AddedEntry[]>([]);
|
||||
const [date, setDate] = useState<Date | undefined>();
|
||||
const [endDate, setEndDate] = useState<Date | undefined>();
|
||||
|
||||
const titleRef = useRef<HTMLInputElement>(null);
|
||||
const closedRef = useRef(false);
|
||||
const [focusedRowId, setFocusedRowId] = useState<string | null>(null);
|
||||
const rowRefs = useRef<Map<string, HTMLLIElement>>(new Map());
|
||||
const stagedListRef = useRef<HTMLUListElement>(null);
|
||||
|
||||
const showProjectSelect = !defaultProjectId;
|
||||
const projectLocked = staged.length > 0;
|
||||
const isActivity = type === 'activity';
|
||||
|
||||
const { data: projectsList } = trpc.projects.listAll.useQuery(undefined, {
|
||||
enabled: showProjectSelect,
|
||||
});
|
||||
const utils = trpc.useUtils();
|
||||
const { notify, notifyError } = useNotify();
|
||||
const createEvent = trpc.timelineEvents.create.useMutation();
|
||||
|
||||
const createEvent = trpc.timelineEvents.create.useMutation({
|
||||
onSuccess: (data, variables) => {
|
||||
notify('success', 'toast.timeline.created');
|
||||
void utils.timelineEvents.list.invalidate();
|
||||
onRecordHistory?.({
|
||||
kind: 'create',
|
||||
id: data.id,
|
||||
payload: {
|
||||
id: data.id,
|
||||
projectId: variables.projectId ?? null,
|
||||
title: variables.title,
|
||||
date: variables.date,
|
||||
endDate: variables.endDate ?? null,
|
||||
type: (variables.type ?? 'milestone') as 'milestone' | 'checkpoint' | 'activity',
|
||||
isCompleted: 0,
|
||||
isAiSuggested: 0,
|
||||
},
|
||||
});
|
||||
setAdded((prev) => [
|
||||
...prev,
|
||||
{
|
||||
title: variables.title,
|
||||
date: new Date(variables.date),
|
||||
endDate: variables.endDate ? new Date(variables.endDate) : undefined,
|
||||
type: variables.type as TimelineEventType,
|
||||
},
|
||||
]);
|
||||
setTitle('');
|
||||
setDateRange(undefined);
|
||||
setSingleDate(undefined);
|
||||
},
|
||||
onError: (err) => notifyError('toast.timeline.createError', err),
|
||||
});
|
||||
function resetForm() {
|
||||
setTitle('');
|
||||
setDate(undefined);
|
||||
setEndDate(undefined);
|
||||
setMode({ kind: 'add' });
|
||||
setTimeout(() => titleRef.current?.focus(), 0);
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
closedRef.current = true;
|
||||
setTitle('');
|
||||
setType('milestone');
|
||||
setDateRange(undefined);
|
||||
setSingleDate(undefined);
|
||||
setDate(undefined);
|
||||
setEndDate(undefined);
|
||||
setProjectId(defaultProjectId ?? '');
|
||||
setAdded([]);
|
||||
setStaged([]);
|
||||
setMode({ kind: 'add' });
|
||||
onOpenChange(false);
|
||||
}
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
const pid = defaultProjectId || projectId || undefined;
|
||||
function attemptClose() {
|
||||
if (staged.length === 0) {
|
||||
handleClose();
|
||||
return;
|
||||
}
|
||||
const ok = window.confirm(t('timeline.confirmCloseStaged', { count: staged.length }));
|
||||
if (ok) handleClose();
|
||||
}
|
||||
|
||||
if (isActivity) {
|
||||
if (!title.trim() || !dateRange?.from) return;
|
||||
const hasDuration = dateRange.to && dateRange.to.getTime() !== dateRange.from.getTime();
|
||||
createEvent.mutate({
|
||||
title: title.trim(),
|
||||
date: dateRange.from.getTime(),
|
||||
endDate: hasDuration ? dateRange.to!.getTime() : undefined,
|
||||
type: 'activity',
|
||||
projectId: pid,
|
||||
});
|
||||
function formValid(): boolean {
|
||||
if (!title.trim()) return false;
|
||||
if (!date) return false;
|
||||
if (isActivity && endDate && endDate < date) return false;
|
||||
if (showProjectSelect && !projectId) return false;
|
||||
return true;
|
||||
}
|
||||
|
||||
function stageOrUpdate() {
|
||||
if (!formValid() || !date) return;
|
||||
const entry: StagedEvent = {
|
||||
id: mode.kind === 'edit' ? mode.id : newLocalId(),
|
||||
title: title.trim(),
|
||||
type,
|
||||
date,
|
||||
endDate: isActivity ? endDate : undefined,
|
||||
};
|
||||
if (mode.kind === 'edit') {
|
||||
setStaged((prev) => prev.map((e) => (e.id === entry.id ? entry : e)));
|
||||
} else {
|
||||
if (!title.trim() || !singleDate) return;
|
||||
createEvent.mutate({
|
||||
title: title.trim(),
|
||||
date: singleDate.getTime(),
|
||||
type,
|
||||
projectId: pid,
|
||||
});
|
||||
setStaged((prev) => [...prev, entry]);
|
||||
}
|
||||
resetForm();
|
||||
}
|
||||
|
||||
async function saveBatch() {
|
||||
if (staged.length === 0) return;
|
||||
const pid = defaultProjectId || projectId || undefined;
|
||||
closedRef.current = false;
|
||||
|
||||
const results = await Promise.allSettled(
|
||||
staged.map((e) =>
|
||||
createEvent.mutateAsync({
|
||||
title: e.title,
|
||||
date: e.date.getTime(),
|
||||
endDate: e.endDate ? e.endDate.getTime() : undefined,
|
||||
type: e.type,
|
||||
projectId: pid,
|
||||
}),
|
||||
),
|
||||
);
|
||||
|
||||
let okCount = 0;
|
||||
const failedIds = new Set<string>();
|
||||
results.forEach((r, i) => {
|
||||
const s = staged[i];
|
||||
if (r.status === 'fulfilled') {
|
||||
okCount += 1;
|
||||
onRecordHistory?.({
|
||||
kind: 'create',
|
||||
id: r.value.id,
|
||||
payload: {
|
||||
id: r.value.id,
|
||||
projectId: pid ?? null,
|
||||
title: s.title,
|
||||
date: s.date.getTime(),
|
||||
endDate: s.endDate ? s.endDate.getTime() : null,
|
||||
type: s.type,
|
||||
isCompleted: 0,
|
||||
isAiSuggested: 0,
|
||||
},
|
||||
});
|
||||
} else {
|
||||
failedIds.add(s.id);
|
||||
}
|
||||
});
|
||||
|
||||
if (closedRef.current) return;
|
||||
void utils.timelineEvents.list.invalidate();
|
||||
|
||||
if (failedIds.size === 0) {
|
||||
notify('success', 'toast.timeline.batchCreated', { count: okCount });
|
||||
handleClose();
|
||||
return;
|
||||
}
|
||||
if (okCount === 0) {
|
||||
const firstError = results.find((r) => r.status === 'rejected') as PromiseRejectedResult | undefined;
|
||||
notifyError('toast.timeline.batchFailed', firstError?.reason);
|
||||
} else {
|
||||
notify('warning', 'toast.timeline.batchPartial', { ok: okCount, failed: failedIds.size });
|
||||
}
|
||||
setStaged((prev) => prev.filter((e) => failedIds.has(e.id)));
|
||||
}
|
||||
|
||||
function loadRowIntoForm(row: StagedEvent) {
|
||||
setTitle(row.title);
|
||||
setType(row.type);
|
||||
setDate(row.date);
|
||||
setEndDate(row.endDate);
|
||||
setMode({ kind: 'edit', id: row.id });
|
||||
setFocusedRowId(null);
|
||||
setTimeout(() => titleRef.current?.focus(), 0);
|
||||
}
|
||||
|
||||
function removeRow(id: string) {
|
||||
const idx = staged.findIndex((s) => s.id === id);
|
||||
setStaged((prev) => prev.filter((s) => s.id !== id));
|
||||
setFocusedRowId(null);
|
||||
if (mode.kind === 'edit' && mode.id === id) {
|
||||
setMode({ kind: 'add' });
|
||||
}
|
||||
setTimeout(() => {
|
||||
const next = staged[idx + 1] ?? staged[idx - 1];
|
||||
if (next) {
|
||||
const el = rowRefs.current.get(next.id);
|
||||
if (el) {
|
||||
setFocusedRowId(next.id);
|
||||
el.focus();
|
||||
return;
|
||||
}
|
||||
}
|
||||
titleRef.current?.focus();
|
||||
}, 0);
|
||||
}
|
||||
|
||||
function onRowKeyDown(e: React.KeyboardEvent<HTMLLIElement>, row: StagedEvent) {
|
||||
const idx = staged.findIndex((s) => s.id === row.id);
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
const next = staged[idx + 1];
|
||||
if (next) {
|
||||
setFocusedRowId(next.id);
|
||||
rowRefs.current.get(next.id)?.focus();
|
||||
} else {
|
||||
setFocusedRowId(null);
|
||||
titleRef.current?.focus();
|
||||
}
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
const prev = staged[idx - 1];
|
||||
if (prev) {
|
||||
setFocusedRowId(prev.id);
|
||||
rowRefs.current.get(prev.id)?.focus();
|
||||
}
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
loadRowIntoForm(row);
|
||||
} else if (e.key === 'Delete' || e.key === 'Backspace') {
|
||||
e.preventDefault();
|
||||
removeRow(row.id);
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
setFocusedRowId(null);
|
||||
titleRef.current?.focus();
|
||||
}
|
||||
}
|
||||
|
||||
const canSubmit = isActivity ? (title.trim() && dateRange?.from) : (title.trim() && singleDate);
|
||||
function onFormKeyDown(e: React.KeyboardEvent) {
|
||||
if ((e.ctrlKey || e.metaKey) && e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
void saveBatch();
|
||||
} else if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
stageOrUpdate();
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
attemptClose();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(v) => { if (!v) handleClose(); else onOpenChange(v); }}>
|
||||
<DialogContent className="sm:max-w-[420px]">
|
||||
<Dialog open={open} onOpenChange={(v) => { if (!v) attemptClose(); else onOpenChange(v); }}>
|
||||
<DialogContent className="sm:max-w-[440px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('timeline.addEventTitle')}</DialogTitle>
|
||||
<DialogDescription>{t('timeline.addEventDescription')}</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
{added.length > 0 && (
|
||||
<ScrollArea className="max-h-32">
|
||||
<div className="flex flex-col gap-1.5">
|
||||
{added.map((entry, i) => (
|
||||
<div key={i} className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||
<Check className="h-3.5 w-3.5 text-chart-2 shrink-0" />
|
||||
<span className="truncate">{entry.title}</span>
|
||||
<span className="ml-auto text-xs shrink-0">
|
||||
{entry.type === 'milestone' ? t('timeline.typeMilestone') : entry.type === 'checkpoint' ? t('timeline.typeCheckpoint') : t('timeline.typeActivity')}
|
||||
</span>
|
||||
</div>
|
||||
{showProjectSelect && (
|
||||
<Select
|
||||
value={projectId}
|
||||
onValueChange={setProjectId}
|
||||
disabled={projectLocked}
|
||||
>
|
||||
<SelectTrigger title={projectLocked ? t('timeline.projectLocked') : undefined}>
|
||||
<SelectValue placeholder={t('timeline.selectProjectOptional')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{projectsList?.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>
|
||||
))}
|
||||
</div>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
{staged.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground py-2">{t('timeline.emptyStagedHint')}</p>
|
||||
) : (
|
||||
<ScrollArea className="max-h-40 border rounded-md">
|
||||
<ul ref={stagedListRef} className="flex flex-col" role="listbox" aria-label={t('timeline.staged', { count: staged.length })}>
|
||||
{staged.map((e) => (
|
||||
<li
|
||||
key={e.id}
|
||||
ref={(el) => {
|
||||
if (el) rowRefs.current.set(e.id, el);
|
||||
else rowRefs.current.delete(e.id);
|
||||
}}
|
||||
tabIndex={focusedRowId === e.id ? 0 : -1}
|
||||
role="option"
|
||||
aria-selected={focusedRowId === e.id}
|
||||
onKeyDown={(ev) => onRowKeyDown(ev, e)}
|
||||
onFocus={() => setFocusedRowId(e.id)}
|
||||
onBlur={(ev) => {
|
||||
const next = ev.relatedTarget as Node | null;
|
||||
if (!next || !stagedListRef.current?.contains(next)) {
|
||||
setFocusedRowId(null);
|
||||
}
|
||||
}}
|
||||
className={cn(
|
||||
'flex items-center gap-2 px-2 py-1.5 text-sm outline-none',
|
||||
focusedRowId === e.id && 'bg-accent/40',
|
||||
mode.kind === 'edit' && mode.id === e.id && 'ring-1 ring-primary/40',
|
||||
)}
|
||||
>
|
||||
<Check className="h-3.5 w-3.5 text-chart-2 shrink-0" />
|
||||
<span className="truncate flex-1">{e.title}</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{e.type === 'milestone'
|
||||
? t('timeline.typeMilestone')
|
||||
: e.type === 'checkpoint'
|
||||
? t('timeline.typeCheckpoint')
|
||||
: t('timeline.typeActivity')}
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
{e.endDate
|
||||
? `${formatDate(e.date.getTime(), prefs)} – ${formatDate(e.endDate.getTime(), prefs)}`
|
||||
: formatDate(e.date.getTime(), prefs)}
|
||||
</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6 text-muted-foreground hover:text-destructive"
|
||||
aria-label={t('timeline.removeRow')}
|
||||
tabIndex={-1}
|
||||
onClick={() => removeRow(e.id)}
|
||||
>
|
||||
<X className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</ScrollArea>
|
||||
)}
|
||||
|
||||
<form onSubmit={handleSubmit} className="flex flex-col gap-4">
|
||||
{/* Event type selector */}
|
||||
<ToggleGroup
|
||||
type="single"
|
||||
value={type}
|
||||
onValueChange={(v) => { if (v) setType(v as TimelineEventType); }}
|
||||
className="justify-start"
|
||||
>
|
||||
<ToggleGroupItem value="milestone" className="text-xs px-3">{t('timeline.typeMilestone')}</ToggleGroupItem>
|
||||
<ToggleGroupItem value="checkpoint" className="text-xs px-3">{t('timeline.typeCheckpoint')}</ToggleGroupItem>
|
||||
<ToggleGroupItem value="activity" className="text-xs px-3">{t('timeline.typeActivity')}</ToggleGroupItem>
|
||||
</ToggleGroup>
|
||||
<div
|
||||
className={cn(
|
||||
'flex flex-col gap-3 transition-opacity',
|
||||
focusedRowId !== null && 'opacity-50 pointer-events-none',
|
||||
)}
|
||||
onKeyDown={onFormKeyDown}
|
||||
>
|
||||
<Tabs value={type} onValueChange={(v) => setType(v as TimelineEventType)}>
|
||||
<TabsList>
|
||||
<TabsTrigger value="milestone">{t('timeline.typeMilestone')}</TabsTrigger>
|
||||
<TabsTrigger value="checkpoint">{t('timeline.typeCheckpoint')}</TabsTrigger>
|
||||
<TabsTrigger value="activity">{t('timeline.typeActivity')}</TabsTrigger>
|
||||
</TabsList>
|
||||
</Tabs>
|
||||
|
||||
<Input
|
||||
ref={titleRef}
|
||||
placeholder={t('timeline.eventTitlePlaceholder')}
|
||||
aria-label={t('timeline.eventTitlePlaceholder')}
|
||||
value={title}
|
||||
onChange={(e) => setTitle(e.target.value)}
|
||||
required
|
||||
onKeyDown={(e) => {
|
||||
if (
|
||||
e.key === 'ArrowUp' &&
|
||||
staged.length > 0 &&
|
||||
(e.currentTarget.selectionStart ?? 0) === 0
|
||||
) {
|
||||
e.preventDefault();
|
||||
const last = staged[staged.length - 1];
|
||||
setFocusedRowId(last.id);
|
||||
rowRefs.current.get(last.id)?.focus();
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
/>
|
||||
|
||||
{/* Date picker: range for activities, single for milestone/checkpoint */}
|
||||
{isActivity ? (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button type="button" variant="outline" className="justify-start px-2.5 font-normal">
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{dateRange?.from ? (
|
||||
dateRange.to && dateRange.to.getTime() !== dateRange.from.getTime() ? (
|
||||
<>{formatDate(dateRange.from.getTime(), prefs)} – {formatDate(dateRange.to.getTime(), prefs)}</>
|
||||
) : (
|
||||
formatDate(dateRange.from.getTime(), prefs)
|
||||
)
|
||||
) : (
|
||||
<span className="text-muted-foreground">{t('timeline.pickDateRange')}</span>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="range"
|
||||
defaultMonth={dateRange?.from}
|
||||
selected={dateRange}
|
||||
onSelect={setDateRange}
|
||||
numberOfMonths={2}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<div className="flex gap-2">
|
||||
<DateField
|
||||
value={date}
|
||||
onChange={(d) => {
|
||||
setDate(d);
|
||||
if (d && endDate && endDate < d) setEndDate(undefined);
|
||||
}}
|
||||
placeholder={t('timeline.pickStart')}
|
||||
aria-label={t('timeline.pickStart')}
|
||||
className="flex-1"
|
||||
/>
|
||||
<DateField
|
||||
value={endDate}
|
||||
onChange={setEndDate}
|
||||
minDate={date}
|
||||
placeholder={t('timeline.pickEnd')}
|
||||
aria-label={t('timeline.pickEnd')}
|
||||
invalidMessage={
|
||||
date && endDate && endDate < date ? t('timeline.endBeforeStart') : undefined
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button type="button" variant="outline" className="justify-start px-2.5 font-normal">
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{singleDate ? (
|
||||
formatDate(singleDate.getTime(), prefs)
|
||||
) : (
|
||||
<span className="text-muted-foreground">{t('timeline.pickDate')}</span>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={singleDate}
|
||||
onSelect={setSingleDate}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<DateField
|
||||
value={date}
|
||||
onChange={setDate}
|
||||
placeholder={t('timeline.pickDate')}
|
||||
aria-label={t('timeline.pickDate')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{showProjectSelect && (
|
||||
<Select value={projectId} onValueChange={setProjectId}>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder={t('timeline.selectProjectOptional')} />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{projectsList?.map((p) => (
|
||||
<SelectItem key={p.id} value={p.id}>{p.name}</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={handleClose}>
|
||||
{added.length > 0 ? t('common.done') : t('common.cancel')}
|
||||
</Button>
|
||||
<Button type="submit" disabled={!canSubmit || createEvent.isPending}>
|
||||
{added.length > 0 ? t('timeline.addAnother') : t('common.add')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={attemptClose}>
|
||||
{t('common.cancel')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={stageOrUpdate}
|
||||
disabled={!formValid()}
|
||||
>
|
||||
{mode.kind === 'edit' ? t('timeline.update') : t('common.add')}
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
onClick={() => void saveBatch()}
|
||||
disabled={staged.length === 0 || createEvent.isPending}
|
||||
>
|
||||
{t('timeline.saveAll', { count: staged.length })}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
|
||||
@@ -1,8 +1,5 @@
|
||||
import { useState, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useFormatPrefs, formatDate } from '@/lib/date';
|
||||
import { CalendarIcon } from 'lucide-react';
|
||||
import { type DateRange } from 'react-day-picker';
|
||||
import { trpc } from '@/lib/trpc';
|
||||
import { useNotify } from '@/hooks/useNotify';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -14,8 +11,7 @@ import {
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import { DateField } from '@/components/ui/date-field';
|
||||
import { ToggleGroup, ToggleGroupItem } from '@/components/ui/toggle-group';
|
||||
import type { TimelineEvent, TimelineEventType } from './ProjectTimeline';
|
||||
import type { HistoryEntry } from './history-types';
|
||||
@@ -28,11 +24,10 @@ interface EditEventDialogProps {
|
||||
|
||||
export function EditEventDialog({ event, onOpenChange, onRecordHistory }: EditEventDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const prefs = useFormatPrefs();
|
||||
const [title, setTitle] = useState('');
|
||||
const [type, setType] = useState<TimelineEventType>('milestone');
|
||||
const [dateRange, setDateRange] = useState<DateRange | undefined>();
|
||||
const [singleDate, setSingleDate] = useState<Date | undefined>();
|
||||
const [date, setDate] = useState<Date | undefined>();
|
||||
const [endDate, setEndDate] = useState<Date | undefined>();
|
||||
|
||||
const pendingPrevRef = useRef<HistoryEntry | null>(null);
|
||||
|
||||
@@ -42,13 +37,11 @@ export function EditEventDialog({ event, onOpenChange, onRecordHistory }: EditEv
|
||||
if (event) {
|
||||
setTitle(event.title);
|
||||
setType(event.type ?? 'milestone');
|
||||
const from = new Date(event.date);
|
||||
setDate(new Date(event.date));
|
||||
if (event.type === 'activity' && event.endDate) {
|
||||
setDateRange({ from, to: new Date(event.endDate) });
|
||||
setSingleDate(undefined);
|
||||
setEndDate(new Date(event.endDate));
|
||||
} else {
|
||||
setSingleDate(from);
|
||||
setDateRange(undefined);
|
||||
setEndDate(undefined);
|
||||
}
|
||||
}
|
||||
}, [event]);
|
||||
@@ -74,13 +67,11 @@ export function EditEventDialog({ event, onOpenChange, onRecordHistory }: EditEv
|
||||
|
||||
function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
if (!event || !title.trim()) return;
|
||||
if (!event || !title.trim() || !date) return;
|
||||
|
||||
if (isActivity) {
|
||||
if (!dateRange?.from) return;
|
||||
const hasDuration = dateRange.to && dateRange.to.getTime() !== dateRange.from.getTime();
|
||||
const nextDate = dateRange.from.getTime();
|
||||
const nextEndDate = hasDuration ? dateRange.to!.getTime() : null;
|
||||
const nextDate = date.getTime();
|
||||
const nextEndDate = (endDate && endDate.getTime() !== date.getTime()) ? endDate.getTime() : null;
|
||||
pendingPrevRef.current = {
|
||||
kind: 'update',
|
||||
id: event.id,
|
||||
@@ -94,8 +85,7 @@ export function EditEventDialog({ event, onOpenChange, onRecordHistory }: EditEv
|
||||
};
|
||||
updateEvent.mutate({ id: event.id, title: title.trim(), type: 'activity', date: nextDate, endDate: nextEndDate });
|
||||
} else {
|
||||
if (!singleDate) return;
|
||||
const nextDate = singleDate.getTime();
|
||||
const nextDate = date.getTime();
|
||||
pendingPrevRef.current = {
|
||||
kind: 'update',
|
||||
id: event.id,
|
||||
@@ -111,7 +101,7 @@ export function EditEventDialog({ event, onOpenChange, onRecordHistory }: EditEv
|
||||
}
|
||||
}
|
||||
|
||||
const canSubmit = isActivity ? (title.trim() && dateRange?.from) : (title.trim() && singleDate);
|
||||
const canSubmit = title.trim() && date;
|
||||
|
||||
return (
|
||||
<Dialog open={!!event} onOpenChange={onOpenChange}>
|
||||
@@ -140,51 +130,30 @@ export function EditEventDialog({ event, onOpenChange, onRecordHistory }: EditEv
|
||||
/>
|
||||
|
||||
{isActivity ? (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button type="button" variant="outline" className="justify-start px-2.5 font-normal">
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{dateRange?.from ? (
|
||||
dateRange.to && dateRange.to.getTime() !== dateRange.from.getTime() ? (
|
||||
<>{formatDate(dateRange.from.getTime(), prefs)} – {formatDate(dateRange.to.getTime(), prefs)}</>
|
||||
) : (
|
||||
formatDate(dateRange.from.getTime(), prefs)
|
||||
)
|
||||
) : (
|
||||
<span className="text-muted-foreground">{t('timeline.pickDateRange')}</span>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="range"
|
||||
defaultMonth={dateRange?.from}
|
||||
selected={dateRange}
|
||||
onSelect={setDateRange}
|
||||
numberOfMonths={2}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<div className="flex gap-2">
|
||||
<DateField
|
||||
value={date}
|
||||
onChange={setDate}
|
||||
placeholder={t('timeline.pickStart')}
|
||||
aria-label={t('timeline.pickStart')}
|
||||
className="flex-1"
|
||||
/>
|
||||
<DateField
|
||||
value={endDate}
|
||||
onChange={setEndDate}
|
||||
minDate={date}
|
||||
placeholder={t('timeline.pickEnd')}
|
||||
aria-label={t('timeline.pickEnd')}
|
||||
className="flex-1"
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<Popover>
|
||||
<PopoverTrigger asChild>
|
||||
<Button type="button" variant="outline" className="justify-start px-2.5 font-normal">
|
||||
<CalendarIcon className="mr-2 h-4 w-4" />
|
||||
{singleDate ? (
|
||||
formatDate(singleDate.getTime(), prefs)
|
||||
) : (
|
||||
<span className="text-muted-foreground">{t('timeline.pickDate')}</span>
|
||||
)}
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="start">
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={singleDate}
|
||||
onSelect={setSingleDate}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
<DateField
|
||||
value={date}
|
||||
onChange={setDate}
|
||||
placeholder={t('timeline.pickDate')}
|
||||
aria-label={t('timeline.pickDate')}
|
||||
/>
|
||||
)}
|
||||
|
||||
<DialogFooter>
|
||||
|
||||
304
src/renderer/components/ui/date-field.tsx
Normal file
304
src/renderer/components/ui/date-field.tsx
Normal file
@@ -0,0 +1,304 @@
|
||||
import { useEffect, useId, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { CalendarIcon } from 'lucide-react';
|
||||
import { useFormatPrefs, formatDate, type FormatPrefs } from '@/lib/date';
|
||||
import { parseDate, type DateKeywords } from '@/lib/parseDate';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
const HOURS = Array.from({ length: 24 }, (_, i) => String(i).padStart(2, '0'));
|
||||
const MINUTES = ['00', '05', '10', '15', '20', '25', '30', '35', '40', '45', '50', '55'];
|
||||
|
||||
function formatValue(d: Date, prefs: FormatPrefs, withTime: boolean): string {
|
||||
const base = formatDate(d.getTime(), prefs);
|
||||
if (!withTime) return base;
|
||||
const h = String(d.getHours()).padStart(2, '0');
|
||||
const m = String(d.getMinutes()).padStart(2, '0');
|
||||
if (h === '00' && m === '00') return base;
|
||||
return `${base} ${h}:${m}`;
|
||||
}
|
||||
|
||||
function CalendarTimeBody({
|
||||
value,
|
||||
onChange,
|
||||
onCommit,
|
||||
withTime,
|
||||
minDate,
|
||||
onAfterPick,
|
||||
}: {
|
||||
value: Date | undefined;
|
||||
onChange: (d: Date | undefined) => void;
|
||||
onCommit?: (d: Date) => void;
|
||||
withTime: boolean;
|
||||
minDate?: Date;
|
||||
onAfterPick: () => void;
|
||||
}) {
|
||||
const dueHour = value ? String(value.getHours()).padStart(2, '0') : '';
|
||||
const dueMinute = value ? String(value.getMinutes()).padStart(2, '0') : '';
|
||||
|
||||
function applyTime(h: string, m: string) {
|
||||
if (!value) return;
|
||||
const next = new Date(value);
|
||||
next.setHours(parseInt(h, 10), parseInt(m, 10), 0, 0);
|
||||
onChange(next);
|
||||
onCommit?.(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={value}
|
||||
onSelect={(d) => {
|
||||
if (d) {
|
||||
const next = new Date(d);
|
||||
if (value && withTime) {
|
||||
next.setHours(value.getHours(), value.getMinutes(), 0, 0);
|
||||
}
|
||||
onChange(next);
|
||||
onCommit?.(next);
|
||||
}
|
||||
onAfterPick();
|
||||
}}
|
||||
disabled={minDate ? { before: minDate } : undefined}
|
||||
/>
|
||||
{withTime && (
|
||||
<div className="border-t px-3 py-2 flex items-center gap-1.5">
|
||||
<Select value={dueHour} onValueChange={(h) => applyTime(h, dueMinute || '00')} disabled={!value}>
|
||||
<SelectTrigger className="h-8 w-20 text-sm"><SelectValue placeholder="HH" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{HOURS.map((h) => <SelectItem key={h} value={h}>{h}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-muted-foreground text-sm">:</span>
|
||||
<Select
|
||||
value={dueMinute && MINUTES.includes(dueMinute) ? dueMinute : ''}
|
||||
onValueChange={(m) => applyTime(dueHour || '00', m)}
|
||||
disabled={!value}
|
||||
>
|
||||
<SelectTrigger className="h-8 w-20 text-sm"><SelectValue placeholder="MM" /></SelectTrigger>
|
||||
<SelectContent>
|
||||
{MINUTES.map((m) => <SelectItem key={m} value={m}>{m}</SelectItem>)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export type DateFieldProps = {
|
||||
value: Date | undefined;
|
||||
onChange: (d: Date | undefined) => void;
|
||||
onCommit?: (d: Date) => void;
|
||||
placeholder?: string;
|
||||
minDate?: Date;
|
||||
autoFocus?: boolean;
|
||||
invalidMessage?: string;
|
||||
className?: string;
|
||||
'aria-label'?: string;
|
||||
id?: string;
|
||||
withTime?: boolean;
|
||||
flat?: boolean;
|
||||
};
|
||||
|
||||
export function DateField({
|
||||
value,
|
||||
onChange,
|
||||
onCommit,
|
||||
placeholder,
|
||||
minDate,
|
||||
autoFocus,
|
||||
invalidMessage,
|
||||
className,
|
||||
id,
|
||||
withTime,
|
||||
flat,
|
||||
...rest
|
||||
}: DateFieldProps) {
|
||||
const reactId = useId();
|
||||
const fieldId = id ?? reactId;
|
||||
const errorId = `${fieldId}-error`;
|
||||
|
||||
const { t, i18n } = useTranslation();
|
||||
const prefs = useFormatPrefs();
|
||||
const [text, setText] = useState<string>(value ? formatValue(value, prefs, !!withTime) : '');
|
||||
const [focused, setFocused] = useState(false);
|
||||
const [invalid, setInvalid] = useState(false);
|
||||
const [open, setOpen] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!focused) {
|
||||
setText(value ? formatValue(value, prefs, !!withTime) : '');
|
||||
setInvalid(false);
|
||||
}
|
||||
}, [value, focused, prefs, withTime]);
|
||||
|
||||
function getKeywords(): DateKeywords {
|
||||
const today = i18n.t('date.keyword.today', { returnObjects: true }) as unknown;
|
||||
const tomorrow = i18n.t('date.keyword.tomorrow', { returnObjects: true }) as unknown;
|
||||
const yesterday = i18n.t('date.keyword.yesterday', { returnObjects: true }) as unknown;
|
||||
const weekdays = i18n.t('date.keyword.weekdays', { returnObjects: true }) as unknown;
|
||||
return {
|
||||
today: Array.isArray(today) ? (today as string[]) : ['today'],
|
||||
tomorrow: Array.isArray(tomorrow) ? (tomorrow as string[]) : ['tomorrow'],
|
||||
yesterday: Array.isArray(yesterday) ? (yesterday as string[]) : ['yesterday'],
|
||||
weekdays: Array.isArray(weekdays)
|
||||
? (weekdays as string[][])
|
||||
: [['sun'],['mon'],['tue'],['wed'],['thu'],['fri'],['sat']],
|
||||
};
|
||||
}
|
||||
|
||||
function tryParse(raw: string): Date | null {
|
||||
const parsed = parseDate(raw, prefs, getKeywords());
|
||||
if (!parsed) return null;
|
||||
if (minDate && parsed < new Date(minDate.getFullYear(), minDate.getMonth(), minDate.getDate())) {
|
||||
return null;
|
||||
}
|
||||
return parsed;
|
||||
}
|
||||
|
||||
function commit(raw: string, fireCommit: boolean) {
|
||||
if (!raw.trim()) {
|
||||
onChange(undefined);
|
||||
setInvalid(false);
|
||||
return;
|
||||
}
|
||||
const parsed = tryParse(raw);
|
||||
if (parsed) {
|
||||
setInvalid(false);
|
||||
onChange(parsed);
|
||||
if (fireCommit) onCommit?.(parsed);
|
||||
} else {
|
||||
setInvalid(true);
|
||||
}
|
||||
}
|
||||
|
||||
if (flat) {
|
||||
return (
|
||||
<div className={cn('relative', className)}>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
id={fieldId}
|
||||
autoFocus={autoFocus}
|
||||
placeholder={placeholder ?? t('timeline.pickDate')}
|
||||
value={text}
|
||||
onChange={(e) => { setText(e.target.value); setInvalid(false); }}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => { setFocused(false); commit(text, false); }}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
commit(text, true);
|
||||
}
|
||||
}}
|
||||
aria-invalid={invalid || !!invalidMessage}
|
||||
aria-describedby={invalidMessage ? errorId : undefined}
|
||||
aria-label={rest['aria-label']}
|
||||
className={cn((invalid || !!invalidMessage) && 'ring-1 ring-destructive')}
|
||||
/>
|
||||
<div className="mt-2 rounded-md border">
|
||||
<CalendarTimeBody
|
||||
value={value}
|
||||
onChange={(d) => {
|
||||
onChange(d);
|
||||
if (d) {
|
||||
setText(formatValue(d, prefs, !!withTime));
|
||||
setInvalid(false);
|
||||
}
|
||||
}}
|
||||
onCommit={onCommit}
|
||||
withTime={!!withTime}
|
||||
minDate={minDate}
|
||||
onAfterPick={() => { inputRef.current?.focus(); }}
|
||||
/>
|
||||
</div>
|
||||
{invalidMessage && (
|
||||
<p id={errorId} className="mt-1 text-xs text-destructive">{invalidMessage}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('relative', className)}>
|
||||
<Input
|
||||
ref={inputRef}
|
||||
id={fieldId}
|
||||
autoFocus={autoFocus}
|
||||
placeholder={placeholder ?? t('timeline.pickDate')}
|
||||
value={text}
|
||||
onChange={(e) => {
|
||||
setText(e.target.value);
|
||||
setInvalid(false);
|
||||
}}
|
||||
onFocus={() => setFocused(true)}
|
||||
onBlur={() => {
|
||||
setFocused(false);
|
||||
commit(text, false);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
commit(text, true);
|
||||
} else if (e.altKey && e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setOpen(true);
|
||||
}
|
||||
}}
|
||||
aria-invalid={invalid || !!invalidMessage}
|
||||
aria-describedby={invalidMessage ? errorId : undefined}
|
||||
aria-label={rest['aria-label']}
|
||||
className={cn('pr-8', (invalid || !!invalidMessage) && 'ring-1 ring-destructive')}
|
||||
/>
|
||||
<Popover open={open} onOpenChange={setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="absolute right-0 top-0 h-full w-8 text-muted-foreground hover:text-foreground"
|
||||
aria-label={t('timeline.pickDate')}
|
||||
tabIndex={-1}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<CalendarIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent className="w-auto p-0" align="end">
|
||||
<CalendarTimeBody
|
||||
value={value}
|
||||
onChange={(d) => {
|
||||
onChange(d);
|
||||
if (d) {
|
||||
setText(formatValue(d, prefs, !!withTime));
|
||||
setInvalid(false);
|
||||
}
|
||||
}}
|
||||
onCommit={onCommit}
|
||||
withTime={!!withTime}
|
||||
minDate={minDate}
|
||||
onAfterPick={() => {
|
||||
setOpen(false);
|
||||
inputRef.current?.focus();
|
||||
}}
|
||||
/>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
{invalidMessage && (
|
||||
<p id={errorId} className="mt-1 text-xs text-destructive">{invalidMessage}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
342
src/renderer/components/ui/datetime-field.tsx
Normal file
342
src/renderer/components/ui/datetime-field.tsx
Normal file
@@ -0,0 +1,342 @@
|
||||
import { memo, useCallback, useMemo, useRef, useState } from 'react';
|
||||
import type { KeyboardEvent as ReactKeyboardEvent } from 'react';
|
||||
import { useFormatPrefs, type FormatPrefs } from '@/lib/date';
|
||||
import { Calendar } from '@/components/ui/calendar';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface DateTimeFieldProps {
|
||||
value: Date | undefined;
|
||||
onChange: (d: Date | undefined) => void;
|
||||
onCommit?: (d: Date) => void;
|
||||
withTime?: boolean;
|
||||
className?: string;
|
||||
'aria-label'?: string;
|
||||
}
|
||||
|
||||
type SegKey = 'day' | 'month' | 'year' | 'hour' | 'minute';
|
||||
interface SegDef {
|
||||
key: SegKey;
|
||||
len: number;
|
||||
min: number;
|
||||
max: number;
|
||||
placeholder: string;
|
||||
}
|
||||
|
||||
const SEGS: Record<SegKey, SegDef> = {
|
||||
day: { key: 'day', len: 2, min: 1, max: 31, placeholder: 'DD' },
|
||||
month: { key: 'month', len: 2, min: 1, max: 12, placeholder: 'MM' },
|
||||
year: { key: 'year', len: 4, min: 1900, max: 2100, placeholder: 'YYYY' },
|
||||
hour: { key: 'hour', len: 2, min: 0, max: 23, placeholder: 'HH' },
|
||||
minute: { key: 'minute', len: 2, min: 0, max: 59, placeholder: 'mm' },
|
||||
};
|
||||
|
||||
type LayoutEntry = { seg: SegKey; sep: string | null };
|
||||
|
||||
function layoutForFormat(fmt: FormatPrefs['dateFormat']): LayoutEntry[] {
|
||||
switch (fmt) {
|
||||
case 'MM/dd/yyyy': return [{ seg: 'month', sep: '/' }, { seg: 'day', sep: '/' }, { seg: 'year', sep: null }];
|
||||
case 'yyyy-MM-dd': return [{ seg: 'year', sep: '-' }, { seg: 'month', sep: '-' }, { seg: 'day', sep: null }];
|
||||
default: return [{ seg: 'day', sep: '/' }, { seg: 'month', sep: '/' }, { seg: 'year', sep: null }];
|
||||
}
|
||||
}
|
||||
|
||||
function clamp(n: number, min: number, max: number) {
|
||||
return Math.max(min, Math.min(max, n));
|
||||
}
|
||||
|
||||
type SegState = Record<SegKey, string>;
|
||||
|
||||
function fromDate(d: Date | undefined): SegState {
|
||||
if (!d) return { day: '', month: '', year: '', hour: '', minute: '' };
|
||||
return {
|
||||
day: String(d.getDate()).padStart(2, '0'),
|
||||
month: String(d.getMonth() + 1).padStart(2, '0'),
|
||||
year: String(d.getFullYear()),
|
||||
hour: String(d.getHours()).padStart(2, '0'),
|
||||
minute: String(d.getMinutes()).padStart(2, '0'),
|
||||
};
|
||||
}
|
||||
|
||||
function toDate(state: SegState, withTime: boolean): Date | undefined {
|
||||
const d = parseInt(state.day, 10);
|
||||
const m = parseInt(state.month, 10);
|
||||
const y = parseInt(state.year, 10);
|
||||
if (!d || !m || !y) return undefined;
|
||||
if (y < 1900 || y > 2100) return undefined;
|
||||
const h = withTime && state.hour !== '' ? parseInt(state.hour, 10) : 0;
|
||||
const mn = withTime && state.minute !== '' ? parseInt(state.minute, 10) : 0;
|
||||
const dt = new Date(y, m - 1, d, h, mn, 0, 0);
|
||||
if (dt.getFullYear() !== y || dt.getMonth() !== m - 1 || dt.getDate() !== d) return undefined;
|
||||
return dt;
|
||||
}
|
||||
|
||||
export function DateTimeField({
|
||||
value,
|
||||
onChange,
|
||||
onCommit,
|
||||
withTime = false,
|
||||
className,
|
||||
...rest
|
||||
}: DateTimeFieldProps) {
|
||||
const prefs = useFormatPrefs();
|
||||
const dateLayout = useMemo(() => layoutForFormat(prefs.dateFormat), [prefs.dateFormat]);
|
||||
const order: SegKey[] = useMemo(() => {
|
||||
const base = dateLayout.map((l) => l.seg);
|
||||
return withTime ? [...base, 'hour', 'minute'] : base;
|
||||
}, [dateLayout, withTime]);
|
||||
|
||||
const [seg, setSeg] = useState<SegState>(() => fromDate(value));
|
||||
const refs = useRef<Record<SegKey, HTMLSpanElement | null>>({
|
||||
day: null, month: null, year: null, hour: null, minute: null,
|
||||
});
|
||||
// Stable per-segment ref setters (avoid new-function-per-render).
|
||||
const refSetters = useRef<Record<SegKey, (el: HTMLSpanElement | null) => void>>({
|
||||
day: (el) => { refs.current.day = el; },
|
||||
month: (el) => { refs.current.month = el; },
|
||||
year: (el) => { refs.current.year = el; },
|
||||
hour: (el) => { refs.current.hour = el; },
|
||||
minute: (el) => { refs.current.minute = el; },
|
||||
});
|
||||
|
||||
function focusSeg(key: SegKey) {
|
||||
const el = refs.current[key];
|
||||
if (!el) return;
|
||||
el.focus();
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(el);
|
||||
const sel = window.getSelection();
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(range);
|
||||
}
|
||||
|
||||
// Note: typing updates LOCAL state only. We deliberately don't call
|
||||
// onChange on every keystroke — otherwise the parent re-renders on each
|
||||
// keypress, which re-renders the (heavy) Calendar grid and the rest of
|
||||
// TaskFormDialog. onChange only fires on commit (Enter) or calendar pick.
|
||||
|
||||
// Stable across renders: uses functional setSeg, refs, and order via ref.
|
||||
const orderRef = useRef(order);
|
||||
orderRef.current = order;
|
||||
const withTimeRef = useRef(withTime);
|
||||
withTimeRef.current = withTime;
|
||||
const onChangeRef = useRef(onChange);
|
||||
onChangeRef.current = onChange;
|
||||
const onCommitRef = useRef(onCommit);
|
||||
onCommitRef.current = onCommit;
|
||||
|
||||
const onSegKeyDown = useCallback((e: ReactKeyboardEvent<HTMLSpanElement>, key: SegKey) => {
|
||||
const def = SEGS[key];
|
||||
|
||||
if (e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
const idx = orderRef.current.indexOf(key);
|
||||
const nxt = orderRef.current[idx + 1];
|
||||
if (nxt) focusSeg(nxt);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
const idx = orderRef.current.indexOf(key);
|
||||
const prv = orderRef.current[idx - 1];
|
||||
if (prv) focusSeg(prv);
|
||||
return;
|
||||
}
|
||||
if (e.key === 'ArrowUp' || e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
setSeg((prev) => {
|
||||
const cur = prev[key];
|
||||
const delta = e.key === 'ArrowUp' ? 1 : -1;
|
||||
const base = cur === '' ? (key === 'hour' || key === 'minute' ? 0 : def.min) : parseInt(cur, 10);
|
||||
let n = base + delta;
|
||||
if (n < def.min) n = def.max;
|
||||
if (n > def.max) n = def.min;
|
||||
return { ...prev, [key]: String(n).padStart(def.len, '0') };
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Backspace' || e.key === 'Delete') {
|
||||
e.preventDefault();
|
||||
setSeg((prev) => {
|
||||
if (prev[key] === '') {
|
||||
const idx = orderRef.current.indexOf(key);
|
||||
const prv = orderRef.current[idx - 1];
|
||||
if (prv) focusSeg(prv);
|
||||
return prev;
|
||||
}
|
||||
return { ...prev, [key]: '' };
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (/^[0-9]$/.test(e.key)) {
|
||||
e.preventDefault();
|
||||
let advance = false;
|
||||
setSeg((prev) => {
|
||||
const cur = prev[key];
|
||||
const incoming = cur.length >= def.len ? e.key : cur + e.key;
|
||||
const numeric = parseInt(incoming, 10);
|
||||
const final = numeric > def.max ? e.key : incoming;
|
||||
const padded = final.padStart(Math.min(final.length, def.len), '0');
|
||||
if (padded.length >= def.len || parseInt(padded, 10) * 10 > def.max) {
|
||||
advance = true;
|
||||
}
|
||||
return { ...prev, [key]: padded };
|
||||
});
|
||||
if (advance) {
|
||||
const idx = orderRef.current.indexOf(key);
|
||||
const nxt = orderRef.current[idx + 1];
|
||||
if (nxt) focusSeg(nxt);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
// Read current seg via functional updater; commit then propagate.
|
||||
setSeg((prev) => {
|
||||
const today = new Date();
|
||||
const wt = withTimeRef.current;
|
||||
const filled: SegState = {
|
||||
day: prev.day || String(today.getDate()).padStart(2, '0'),
|
||||
month: prev.month || String(today.getMonth() + 1).padStart(2, '0'),
|
||||
year: prev.year || String(today.getFullYear()),
|
||||
hour: wt ? (prev.hour || '00') : '00',
|
||||
minute: wt ? (prev.minute || '00') : '00',
|
||||
};
|
||||
const monthN = clamp(parseInt(filled.month, 10), SEGS.month.min, SEGS.month.max);
|
||||
const yearN = clamp(parseInt(filled.year, 10), SEGS.year.min, SEGS.year.max);
|
||||
const hourN = clamp(parseInt(filled.hour, 10), SEGS.hour.min, SEGS.hour.max);
|
||||
const minuteN = clamp(parseInt(filled.minute, 10), SEGS.minute.min, SEGS.minute.max);
|
||||
const lastDayOfMonth = new Date(yearN, monthN, 0).getDate();
|
||||
const dayN = clamp(parseInt(filled.day, 10), SEGS.day.min, lastDayOfMonth);
|
||||
const dt = new Date(yearN, monthN - 1, dayN, hourN, minuteN, 0, 0);
|
||||
onChangeRef.current(dt);
|
||||
onCommitRef.current?.(dt);
|
||||
return fromDate(dt);
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (e.key === '/' || e.key === '-' || e.key === ':' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
const idx = orderRef.current.indexOf(key);
|
||||
const nxt = orderRef.current[idx + 1];
|
||||
if (nxt) focusSeg(nxt);
|
||||
return;
|
||||
}
|
||||
}, []);
|
||||
|
||||
const onCalendarSelect = useCallback((d: Date | undefined) => {
|
||||
if (!d) return;
|
||||
setSeg((prev) => {
|
||||
const next: SegState = {
|
||||
...prev,
|
||||
day: String(d.getDate()).padStart(2, '0'),
|
||||
month: String(d.getMonth() + 1).padStart(2, '0'),
|
||||
year: String(d.getFullYear()),
|
||||
};
|
||||
const dt = toDate(next, withTime);
|
||||
if (dt) onChange(dt);
|
||||
return next;
|
||||
});
|
||||
}, [withTime, onChange]);
|
||||
|
||||
const selectedDate = toDate(seg, withTime);
|
||||
const selectedMs = selectedDate ? selectedDate.getTime() : null;
|
||||
const calendarEl = useMemo(
|
||||
() => (
|
||||
<Calendar
|
||||
mode="single"
|
||||
selected={selectedDate}
|
||||
onSelect={onCalendarSelect}
|
||||
/>
|
||||
),
|
||||
// selectedMs primary key; selectedDate/onCalendarSelect captured for closure.
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
[selectedMs, onCalendarSelect],
|
||||
);
|
||||
|
||||
return (
|
||||
<div className={cn('flex flex-col gap-3', className)} aria-label={rest['aria-label']}>
|
||||
<div
|
||||
className="inline-flex items-center gap-0.5 rounded-md border border-input bg-background px-3 py-2 text-sm font-mono focus-within:ring-2 focus-within:ring-ring/30 focus-within:border-ring"
|
||||
role="group"
|
||||
>
|
||||
{dateLayout.map(({ seg: sk, sep }) => (
|
||||
<SegmentSpan
|
||||
key={sk}
|
||||
segKey={sk}
|
||||
value={seg[sk]}
|
||||
onKeyDown={onSegKeyDown}
|
||||
registerRef={refSetters.current[sk]}
|
||||
sep={sep}
|
||||
/>
|
||||
))}
|
||||
{withTime && (
|
||||
<>
|
||||
<span className="px-1.5 text-muted-foreground/60 select-none"> </span>
|
||||
<SegmentSpan
|
||||
segKey="hour"
|
||||
value={seg.hour}
|
||||
onKeyDown={onSegKeyDown}
|
||||
registerRef={refSetters.current.hour}
|
||||
sep=":"
|
||||
/>
|
||||
<SegmentSpan
|
||||
segKey="minute"
|
||||
value={seg.minute}
|
||||
onKeyDown={onSegKeyDown}
|
||||
registerRef={refSetters.current.minute}
|
||||
sep={null}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
<div className="rounded-md border">{calendarEl}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const SegmentSpan = memo(function SegmentSpan({
|
||||
segKey,
|
||||
value,
|
||||
onKeyDown,
|
||||
registerRef,
|
||||
sep,
|
||||
}: {
|
||||
segKey: SegKey;
|
||||
value: string;
|
||||
onKeyDown: (e: ReactKeyboardEvent<HTMLSpanElement>, key: SegKey) => void;
|
||||
registerRef: (el: HTMLSpanElement | null) => void;
|
||||
sep: string | null;
|
||||
}) {
|
||||
const def = SEGS[segKey];
|
||||
const isEmpty = value === '';
|
||||
return (
|
||||
<>
|
||||
<span
|
||||
ref={registerRef}
|
||||
tabIndex={0}
|
||||
role="spinbutton"
|
||||
aria-label={def.placeholder}
|
||||
aria-valuemin={def.min}
|
||||
aria-valuemax={def.max}
|
||||
aria-valuenow={isEmpty ? undefined : clamp(parseInt(value, 10), def.min, def.max)}
|
||||
onKeyDown={(e) => onKeyDown(e, segKey)}
|
||||
onFocus={(e) => {
|
||||
const range = document.createRange();
|
||||
range.selectNodeContents(e.currentTarget);
|
||||
const sel = window.getSelection();
|
||||
sel?.removeAllRanges();
|
||||
sel?.addRange(range);
|
||||
}}
|
||||
className={cn(
|
||||
'inline-block text-center outline-none rounded px-0.5 cursor-text tabular-nums',
|
||||
def.len === 4 ? 'min-w-[3.5ch]' : 'min-w-[1.8ch]',
|
||||
isEmpty && 'text-muted-foreground/60',
|
||||
'focus:bg-accent',
|
||||
)}
|
||||
>
|
||||
{isEmpty ? def.placeholder : value}
|
||||
</span>
|
||||
{sep && <span className="text-muted-foreground/70 select-none px-0.5">{sep}</span>}
|
||||
</>
|
||||
);
|
||||
});
|
||||
@@ -59,7 +59,7 @@ function DialogContent({
|
||||
<DialogPrimitive.Content
|
||||
data-slot="dialog-content"
|
||||
className={cn(
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none",
|
||||
"bg-background data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 fixed top-[50%] left-[50%] z-50 grid w-full max-w-[calc(100%-2rem)] sm:max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 rounded-lg border p-6 shadow-lg duration-200 outline-none",
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
|
||||
104
src/renderer/hooks/useListboxKeys.ts
Normal file
104
src/renderer/hooks/useListboxKeys.ts
Normal file
@@ -0,0 +1,104 @@
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import type { KeyboardEvent as ReactKeyboardEvent } from 'react';
|
||||
|
||||
export interface UseListboxKeysOptions {
|
||||
itemCount: number;
|
||||
initialIndex?: number;
|
||||
onSelect: (index: number) => void;
|
||||
onClose: () => void;
|
||||
/**
|
||||
* If true, focus does NOT move out of the listbox on Tab; the caller is
|
||||
* expected to also call onClose so the parent can advance focus.
|
||||
*/
|
||||
closeOnTab?: boolean;
|
||||
}
|
||||
|
||||
export interface ListboxItemProps {
|
||||
tabIndex: number;
|
||||
ref: (el: HTMLElement | null) => void;
|
||||
onKeyDown: (e: ReactKeyboardEvent) => void;
|
||||
onMouseEnter: () => void;
|
||||
'aria-selected': boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* Listbox keyboard model used inside popovers: ↑/↓ move active item,
|
||||
* Home/End jump to ends, Enter/Space selects, Esc closes, Tab closes
|
||||
* (so the parent's tab order resumes from the trigger).
|
||||
*/
|
||||
export function useListboxKeys({
|
||||
itemCount,
|
||||
initialIndex = 0,
|
||||
onSelect,
|
||||
onClose,
|
||||
closeOnTab = true,
|
||||
}: UseListboxKeysOptions) {
|
||||
const [active, setActive] = useState<number>(() => {
|
||||
if (itemCount <= 0) return 0;
|
||||
return Math.max(0, Math.min(itemCount - 1, initialIndex));
|
||||
});
|
||||
const refs = useRef<Array<HTMLElement | null>>([]);
|
||||
const refSetters = useRef<Map<number, (el: HTMLElement | null) => void>>(new Map());
|
||||
|
||||
useEffect(() => {
|
||||
if (active >= itemCount) setActive(Math.max(0, itemCount - 1));
|
||||
}, [itemCount, active]);
|
||||
|
||||
const getRefSetter = useCallback((index: number) => {
|
||||
let setter = refSetters.current.get(index);
|
||||
if (!setter) {
|
||||
setter = (el: HTMLElement | null) => {
|
||||
refs.current[index] = el;
|
||||
};
|
||||
refSetters.current.set(index, setter);
|
||||
}
|
||||
return setter;
|
||||
}, []);
|
||||
|
||||
const focusIndex = useCallback((i: number) => {
|
||||
if (itemCount <= 0) return;
|
||||
const clamped = Math.max(0, Math.min(itemCount - 1, i));
|
||||
setActive(clamped);
|
||||
const el = refs.current[clamped];
|
||||
if (el) el.focus();
|
||||
}, [itemCount]);
|
||||
|
||||
const getItemProps = useCallback(
|
||||
(index: number): ListboxItemProps => ({
|
||||
tabIndex: index === active ? 0 : -1,
|
||||
ref: getRefSetter(index),
|
||||
'aria-selected': index === active,
|
||||
onMouseEnter: () => setActive(index),
|
||||
onKeyDown: (e: ReactKeyboardEvent) => {
|
||||
if (e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
focusIndex(Math.min(index + 1, itemCount - 1));
|
||||
} else if (e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
focusIndex(Math.max(index - 1, 0));
|
||||
} else if (e.key === 'Home') {
|
||||
e.preventDefault();
|
||||
focusIndex(0);
|
||||
} else if (e.key === 'End') {
|
||||
e.preventDefault();
|
||||
focusIndex(itemCount - 1);
|
||||
} else if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onSelect(index);
|
||||
} else if (e.key === 'Escape') {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onClose();
|
||||
} else if (e.key === 'Tab' && closeOnTab) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
}),
|
||||
[active, itemCount, focusIndex, onSelect, onClose, closeOnTab, getRefSetter],
|
||||
);
|
||||
|
||||
return { activeIndex: active, setActive, focusIndex, getItemProps };
|
||||
}
|
||||
98
src/renderer/hooks/useRovingFocus.ts
Normal file
98
src/renderer/hooks/useRovingFocus.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { useCallback, useRef, useState } from 'react';
|
||||
import type { KeyboardEvent as ReactKeyboardEvent } from 'react';
|
||||
|
||||
export type RovingDirection = 'horizontal' | 'vertical' | 'both';
|
||||
|
||||
export interface UseRovingFocusOptions {
|
||||
count: number;
|
||||
direction?: RovingDirection;
|
||||
initialIndex?: number;
|
||||
loop?: boolean;
|
||||
}
|
||||
|
||||
export interface RovingItemProps {
|
||||
tabIndex: number;
|
||||
onKeyDown: (e: ReactKeyboardEvent) => void;
|
||||
onFocus: () => void;
|
||||
ref: (el: HTMLElement | null) => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Roving tabindex helper. Group exposes a single tab stop; arrow keys
|
||||
* move focus between items. Index state is internal — call `getItemProps(i)`
|
||||
* for each item to wire it up.
|
||||
*/
|
||||
export function useRovingFocus({
|
||||
count,
|
||||
direction = 'both',
|
||||
initialIndex = 0,
|
||||
loop = false,
|
||||
}: UseRovingFocusOptions) {
|
||||
const [active, setActive] = useState<number>(() => {
|
||||
if (count <= 0) return 0;
|
||||
return Math.max(0, Math.min(count - 1, initialIndex));
|
||||
});
|
||||
const refs = useRef<Array<HTMLElement | null>>([]);
|
||||
const refSetters = useRef<Map<number, (el: HTMLElement | null) => void>>(new Map());
|
||||
|
||||
const getRefSetter = useCallback((index: number) => {
|
||||
let setter = refSetters.current.get(index);
|
||||
if (!setter) {
|
||||
setter = (el: HTMLElement | null) => {
|
||||
refs.current[index] = el;
|
||||
};
|
||||
refSetters.current.set(index, setter);
|
||||
}
|
||||
return setter;
|
||||
}, []);
|
||||
|
||||
const move = useCallback(
|
||||
(next: number) => {
|
||||
if (count <= 0) return;
|
||||
let target = next;
|
||||
if (loop) {
|
||||
target = ((next % count) + count) % count;
|
||||
} else {
|
||||
target = Math.max(0, Math.min(count - 1, next));
|
||||
}
|
||||
setActive(target);
|
||||
const el = refs.current[target];
|
||||
if (el) el.focus();
|
||||
},
|
||||
[count, loop],
|
||||
);
|
||||
|
||||
const getItemProps = useCallback(
|
||||
(index: number): RovingItemProps => ({
|
||||
tabIndex: index === active ? 0 : -1,
|
||||
ref: getRefSetter(index),
|
||||
onFocus: () => setActive(index),
|
||||
onKeyDown: (e: ReactKeyboardEvent) => {
|
||||
const horizontal = direction === 'horizontal' || direction === 'both';
|
||||
const vertical = direction === 'vertical' || direction === 'both';
|
||||
if (horizontal && e.key === 'ArrowRight') {
|
||||
e.preventDefault();
|
||||
move(index + 1);
|
||||
} else if (horizontal && e.key === 'ArrowLeft') {
|
||||
e.preventDefault();
|
||||
move(index - 1);
|
||||
} else if (vertical && e.key === 'ArrowDown') {
|
||||
e.preventDefault();
|
||||
move(index + 1);
|
||||
} else if (vertical && e.key === 'ArrowUp') {
|
||||
e.preventDefault();
|
||||
move(index - 1);
|
||||
} else if (e.key === 'Home') {
|
||||
e.preventDefault();
|
||||
move(0);
|
||||
} else if (e.key === 'End') {
|
||||
e.preventDefault();
|
||||
move(count - 1);
|
||||
}
|
||||
},
|
||||
}),
|
||||
[active, count, direction, move, getRefSetter],
|
||||
);
|
||||
|
||||
return { activeIndex: active, setActive, getItemProps };
|
||||
}
|
||||
171
src/renderer/lib/parseDate.ts
Normal file
171
src/renderer/lib/parseDate.ts
Normal file
@@ -0,0 +1,171 @@
|
||||
import type { FormatPrefs } from './date';
|
||||
|
||||
type ParseInput = string | null | undefined;
|
||||
|
||||
const RE_REL = /^\s*([+-])\s*(\d+)\s*([dwm])\s*$/i;
|
||||
const RE_NUMERIC = /^(\d{1,4})[/\-.](\d{1,4})(?:[/\-.](\d{1,4}))?$/;
|
||||
const RE_TIME_SUFFIX = /\s+(\d{1,2}):(\d{2})\s*$/;
|
||||
|
||||
function startOfDay(d: Date): Date {
|
||||
const out = new Date(d.getFullYear(), d.getMonth(), d.getDate());
|
||||
return out;
|
||||
}
|
||||
|
||||
function addDays(d: Date, n: number): Date {
|
||||
const out = new Date(d);
|
||||
out.setDate(out.getDate() + n);
|
||||
return out;
|
||||
}
|
||||
|
||||
function addMonths(d: Date, n: number): Date {
|
||||
const out = new Date(d);
|
||||
out.setMonth(out.getMonth() + n);
|
||||
return out;
|
||||
}
|
||||
|
||||
function pivotYear(twoDigit: number): number {
|
||||
const now = new Date().getFullYear();
|
||||
const century = Math.floor(now / 100) * 100;
|
||||
const offset = now % 100;
|
||||
return twoDigit > (offset + 50) ? century - 100 + twoDigit : century + twoDigit;
|
||||
}
|
||||
|
||||
function parseNumeric(
|
||||
input: string,
|
||||
prefs: FormatPrefs,
|
||||
base: Date,
|
||||
): Date | null {
|
||||
const m = input.match(RE_NUMERIC);
|
||||
if (!m) return null;
|
||||
let day: number, month: number, year: number;
|
||||
const a = parseInt(m[1], 10);
|
||||
const b = parseInt(m[2], 10);
|
||||
const c = m[3] != null ? parseInt(m[3], 10) : NaN;
|
||||
|
||||
if (prefs.dateFormat === 'yyyy-MM-dd') {
|
||||
if (m[3] == null) return null;
|
||||
year = a; month = b; day = c;
|
||||
} else if (prefs.dateFormat === 'MM/dd/yyyy') {
|
||||
month = a; day = b;
|
||||
if (m[3] == null) year = base.getFullYear();
|
||||
else year = c < 100 ? pivotYear(c) : c;
|
||||
} else {
|
||||
// default: 'dd/MM/yyyy'
|
||||
day = a; month = b;
|
||||
if (m[3] == null) year = base.getFullYear();
|
||||
else year = c < 100 ? pivotYear(c) : c;
|
||||
}
|
||||
|
||||
if (month < 1 || month > 12) return null;
|
||||
const result = new Date(year, month - 1, day);
|
||||
if (result.getFullYear() !== year || result.getMonth() !== month - 1 || result.getDate() !== day) {
|
||||
return null;
|
||||
}
|
||||
if (m[3] == null && result < startOfDay(base)) {
|
||||
result.setFullYear(year + 1);
|
||||
if (result.getDate() !== day) return null;
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function parseKeyword(
|
||||
word: string,
|
||||
keywords: { today: string[]; tomorrow: string[]; yesterday: string[]; weekdays: string[][] },
|
||||
base: Date,
|
||||
): Date | null {
|
||||
const w = word.trim().toLowerCase();
|
||||
if (keywords.today.some((k) => k.toLowerCase() === w)) return startOfDay(base);
|
||||
if (keywords.tomorrow.some((k) => k.toLowerCase() === w)) return addDays(startOfDay(base), 1);
|
||||
if (keywords.yesterday.some((k) => k.toLowerCase() === w)) return addDays(startOfDay(base), -1);
|
||||
for (let i = 0; i < keywords.weekdays.length; i++) {
|
||||
if (keywords.weekdays[i].some((k) => k.toLowerCase() === w)) {
|
||||
const today = startOfDay(base);
|
||||
const diff = (i - today.getDay() + 7) % 7 || 7;
|
||||
return addDays(today, diff);
|
||||
}
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
export type DateKeywords = {
|
||||
today: string[];
|
||||
tomorrow: string[];
|
||||
yesterday: string[];
|
||||
/** Index 0=Sunday, 6=Saturday. Each entry: aliases (short + long). */
|
||||
weekdays: string[][];
|
||||
};
|
||||
|
||||
export function parseDate(
|
||||
input: ParseInput,
|
||||
prefs: FormatPrefs,
|
||||
keywords: DateKeywords,
|
||||
baseDate: Date = new Date(),
|
||||
): Date | null {
|
||||
if (!input) return null;
|
||||
const raw = input.trim();
|
||||
if (!raw) return null;
|
||||
|
||||
let datePart = raw;
|
||||
let hours: number | null = null;
|
||||
let minutes: number | null = null;
|
||||
const tm = raw.match(RE_TIME_SUFFIX);
|
||||
if (tm) {
|
||||
const h = parseInt(tm[1], 10);
|
||||
const m = parseInt(tm[2], 10);
|
||||
if (h < 0 || h > 23 || m < 0 || m > 59) return null;
|
||||
hours = h;
|
||||
minutes = m;
|
||||
datePart = raw.slice(0, raw.length - tm[0].length).trim();
|
||||
if (!datePart) return null;
|
||||
}
|
||||
|
||||
const rel = datePart.match(RE_REL);
|
||||
if (rel) {
|
||||
const sign = rel[1] === '-' ? -1 : 1;
|
||||
const n = sign * parseInt(rel[2], 10);
|
||||
const unit = rel[3].toLowerCase();
|
||||
const base = startOfDay(baseDate);
|
||||
let result: Date;
|
||||
if (unit === 'd') result = addDays(base, n);
|
||||
else if (unit === 'w') result = addDays(base, n * 7);
|
||||
else result = addMonths(base, n);
|
||||
if (hours != null && minutes != null) result.setHours(hours, minutes, 0, 0);
|
||||
return result;
|
||||
}
|
||||
|
||||
const kw = parseKeyword(datePart, keywords, baseDate);
|
||||
if (kw) {
|
||||
if (hours != null && minutes != null) kw.setHours(hours, minutes, 0, 0);
|
||||
return kw;
|
||||
}
|
||||
|
||||
const num = parseNumeric(datePart, prefs, baseDate);
|
||||
if (num) {
|
||||
if (hours != null && minutes != null) num.setHours(hours, minutes, 0, 0);
|
||||
return num;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function parseDateRange(
|
||||
input: ParseInput,
|
||||
prefs: FormatPrefs,
|
||||
keywords: DateKeywords,
|
||||
baseDate: Date = new Date(),
|
||||
): { from: Date; to?: Date } | null {
|
||||
if (!input) return null;
|
||||
const parts = input.split(/\s+(?:-{1,2}|–|to)\s+/i);
|
||||
if (parts.length === 1) {
|
||||
const single = parseDate(parts[0], prefs, keywords, baseDate);
|
||||
return single ? { from: single } : null;
|
||||
}
|
||||
if (parts.length === 2) {
|
||||
const from = parseDate(parts[0], prefs, keywords, baseDate);
|
||||
if (!from) return null;
|
||||
const to = parseDate(parts[1], prefs, keywords, from);
|
||||
if (!to) return null;
|
||||
return { from, to };
|
||||
}
|
||||
return null;
|
||||
}
|
||||
@@ -41,6 +41,8 @@
|
||||
"priority": "Priorität",
|
||||
"createdDate": "Erstelldatum",
|
||||
"newTask": "Neue Aufgabe",
|
||||
"newTaskDescription": "Halten Sie fest, was zu tun ist. Eigenschaften unten oder später setzen.",
|
||||
"editTaskDescription": "Aktualisieren Sie Details, Eigenschaften und Anhänge der Aufgabe.",
|
||||
"noTasksFound": "Keine Aufgaben gefunden",
|
||||
"noTasksDescription": "Erstellen Sie eine neue Aufgabe oder passen Sie Ihre Filter an.",
|
||||
"taskTitle": "Aufgabentitel",
|
||||
@@ -309,16 +311,34 @@
|
||||
"zoomIn": "Vergrößern",
|
||||
"zoomOut": "Verkleinern",
|
||||
"addEventTitle": "Zeitleisten-Ereignis hinzufügen",
|
||||
"addEventDescription": "Bereite ein oder mehrere Ereignisse für das Projekt vor und speichere sie gemeinsam.",
|
||||
"editEventTitle": "Zeitleisten-Ereignis bearbeiten",
|
||||
"eventTitlePlaceholder": "Ereignistitel",
|
||||
"pickDate": "Datum auswählen",
|
||||
"pickDateRange": "Datumsbereich auswählen",
|
||||
"pickStart": "Startdatum",
|
||||
"pickEnd": "Enddatum",
|
||||
"selectProjectOptional": "Projekt auswählen (optional)",
|
||||
"addAnother": "Weiteres hinzufügen",
|
||||
"markAsDone": "Als erledigt markieren",
|
||||
"markAsToDo": "Als zu erledigen markieren",
|
||||
"editEvent": "Ereignis bearbeiten",
|
||||
"deleteEvent": "Ereignis löschen"
|
||||
"deleteEvent": "Ereignis löschen",
|
||||
"endBeforeStart": "Ende muss nach dem Start liegen",
|
||||
"dateInvalid": "Datum nicht erkannt",
|
||||
"batchCreated_one": "1 Ereignis erstellt",
|
||||
"batchCreated_other": "{{count}} Ereignisse erstellt",
|
||||
"batchPartial": "{{ok}} erstellt, {{failed}} fehlgeschlagen",
|
||||
"batchFailed": "Ereignisse konnten nicht erstellt werden",
|
||||
"staged_one": "1 Ereignis vorbereitet",
|
||||
"staged_other": "{{count}} Ereignisse vorbereitet",
|
||||
"emptyStagedHint": "Titel eingeben, Datum festlegen, Enter drücken",
|
||||
"editRow": "Bearbeiten",
|
||||
"removeRow": "Entfernen",
|
||||
"projectLocked": "Projekt nach dem ersten Ereignis gesperrt",
|
||||
"confirmCloseStaged": "{{count}} vorbereitete Ereignisse verwerfen?",
|
||||
"saveAll": "{{count}} speichern",
|
||||
"update": "Aktualisieren"
|
||||
},
|
||||
"projects": {
|
||||
"noProjectSelected": "Kein Projekt ausgewählt",
|
||||
@@ -373,7 +393,30 @@
|
||||
"untitledNote": "Unbenannte Notiz",
|
||||
"noNotesYet": "Noch keine Notizen.",
|
||||
"noNotesYetDescription": "Klicke auf \"+ Hinzufügen\", um deine erste Notiz zu erstellen.",
|
||||
"overview": "Übersicht"
|
||||
"overview": "Übersicht",
|
||||
"folder": {
|
||||
"title": "Dateien",
|
||||
"linkCta": "Ordner verknüpfen",
|
||||
"browse": "Durchsuchen",
|
||||
"rescan": "Neu scannen",
|
||||
"unlink": "Verknüpfung aufheben",
|
||||
"filesCount_one": "{{count}} Datei",
|
||||
"filesCount_other": "{{count}} Dateien",
|
||||
"lastScanned": "zuletzt gescannt {{relative}}",
|
||||
"scanning": "Indizierung {{processed}}/{{total}}",
|
||||
"scanFailed": "Scan fehlgeschlagen",
|
||||
"empty": {
|
||||
"title": "Einen Projektordner verknüpfen",
|
||||
"description": "Verbinden Sie einen lokalen Ordner, damit KI-Agenten dessen Dateien lesen können, wenn sie Fragen zu diesem Projekt beantworten.",
|
||||
"cta": "Ordner auswählen…"
|
||||
},
|
||||
"errors": {
|
||||
"tooBig": "Ordner zu groß für den {{tier}}-Plan — max. {{count}} Dateien",
|
||||
"monthlyExhausted": "Monatliches Token-Budget erschöpft (zurückgesetzt am {{date}})",
|
||||
"notFound": "Ordner nicht gefunden: {{path}}"
|
||||
},
|
||||
"webOnlyTooltip": "Ordnerverknüpfung ist in der Desktop-App verfügbar"
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"title": "Agenten",
|
||||
@@ -454,7 +497,11 @@
|
||||
"updateError": "Ereignis konnte nicht aktualisiert werden",
|
||||
"deleted": "Ereignis gelöscht",
|
||||
"deleteError": "Ereignis konnte nicht gelöscht werden",
|
||||
"historyError": "Rückgängig nicht möglich — das Ereignis wurde möglicherweise geändert."
|
||||
"historyError": "Rückgängig nicht möglich — das Ereignis wurde möglicherweise geändert.",
|
||||
"batchCreated_one": "1 Ereignis erstellt",
|
||||
"batchCreated_other": "{{count}} Ereignisse erstellt",
|
||||
"batchPartial": "{{ok}} erstellt, {{failed}} fehlgeschlagen",
|
||||
"batchFailed": "Ereignisse konnten nicht erstellt werden"
|
||||
},
|
||||
"comment": {
|
||||
"created": "Kommentar hinzugefügt",
|
||||
@@ -476,5 +523,21 @@
|
||||
"runStarted": "Agent-Ausführung gestartet",
|
||||
"runError": "Agent konnte nicht gestartet werden"
|
||||
}
|
||||
},
|
||||
"date": {
|
||||
"keyword": {
|
||||
"today": ["heute"],
|
||||
"tomorrow": ["morgen"],
|
||||
"yesterday": ["gestern"],
|
||||
"weekdays": [
|
||||
["so", "sonntag"],
|
||||
["mo", "montag"],
|
||||
["di", "dienstag"],
|
||||
["mi", "mittwoch"],
|
||||
["do", "donnerstag"],
|
||||
["fr", "freitag"],
|
||||
["sa", "samstag"]
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,8 @@
|
||||
"priority": "Priority",
|
||||
"createdDate": "Created Date",
|
||||
"newTask": "New Task",
|
||||
"newTaskDescription": "Capture what needs doing. Set properties below or refine later.",
|
||||
"editTaskDescription": "Update the task details, properties, and attachments.",
|
||||
"noTasksFound": "No tasks found",
|
||||
"noTasksDescription": "Create a new task to get started or adjust your filters.",
|
||||
"taskTitle": "Task title",
|
||||
@@ -309,16 +311,34 @@
|
||||
"zoomIn": "Zoom in",
|
||||
"zoomOut": "Zoom out",
|
||||
"addEventTitle": "Add Timeline Event",
|
||||
"addEventDescription": "Stage one or more events for the project, then save them all together.",
|
||||
"editEventTitle": "Edit Timeline Event",
|
||||
"eventTitlePlaceholder": "Event title",
|
||||
"pickDate": "Pick a date",
|
||||
"pickDateRange": "Pick a date range",
|
||||
"pickStart": "Pick start date",
|
||||
"pickEnd": "Pick end date",
|
||||
"selectProjectOptional": "Select project (optional)",
|
||||
"addAnother": "Add Another",
|
||||
"markAsDone": "Mark as done",
|
||||
"markAsToDo": "Mark as to-do",
|
||||
"editEvent": "Edit event",
|
||||
"deleteEvent": "Delete event"
|
||||
"deleteEvent": "Delete event",
|
||||
"endBeforeStart": "End must be after start",
|
||||
"dateInvalid": "Unrecognized date",
|
||||
"batchCreated_one": "1 event created",
|
||||
"batchCreated_other": "{{count}} events created",
|
||||
"batchPartial": "{{ok}} created, {{failed}} failed",
|
||||
"batchFailed": "Could not create events",
|
||||
"staged_one": "1 event staged",
|
||||
"staged_other": "{{count}} events staged",
|
||||
"emptyStagedHint": "Type a title, set a date, press Enter",
|
||||
"editRow": "Edit",
|
||||
"removeRow": "Remove",
|
||||
"projectLocked": "Project locked after first event",
|
||||
"confirmCloseStaged": "Discard {{count}} staged events?",
|
||||
"saveAll": "Save {{count}}",
|
||||
"update": "Update"
|
||||
},
|
||||
"projects": {
|
||||
"noProjectSelected": "No project selected",
|
||||
@@ -373,7 +393,30 @@
|
||||
"untitledNote": "Untitled Note",
|
||||
"noNotesYet": "No notes yet.",
|
||||
"noNotesYetDescription": "Click \"+ Add\" to create your first note.",
|
||||
"overview": "Overview"
|
||||
"overview": "Overview",
|
||||
"folder": {
|
||||
"title": "Files",
|
||||
"linkCta": "Link folder",
|
||||
"browse": "Browse",
|
||||
"rescan": "Rescan",
|
||||
"unlink": "Unlink",
|
||||
"filesCount_one": "{{count}} file",
|
||||
"filesCount_other": "{{count}} files",
|
||||
"lastScanned": "last scanned {{relative}}",
|
||||
"scanning": "indexing {{processed}}/{{total}}",
|
||||
"scanFailed": "Scan failed",
|
||||
"empty": {
|
||||
"title": "Link a project folder",
|
||||
"description": "Connect a local folder so AI agents can read its files when answering questions about this project.",
|
||||
"cta": "Choose folder…"
|
||||
},
|
||||
"errors": {
|
||||
"tooBig": "Folder too big for {{tier}} plan — max {{count}} files",
|
||||
"monthlyExhausted": "Monthly token budget exhausted (resets {{date}})",
|
||||
"notFound": "Folder not found: {{path}}"
|
||||
},
|
||||
"webOnlyTooltip": "Folder linking available in desktop app"
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"title": "Agents",
|
||||
@@ -454,7 +497,11 @@
|
||||
"updateError": "Failed to update event",
|
||||
"deleted": "Event deleted",
|
||||
"deleteError": "Failed to delete event",
|
||||
"historyError": "Couldn't undo — event may have changed."
|
||||
"historyError": "Couldn't undo — event may have changed.",
|
||||
"batchCreated_one": "1 event created",
|
||||
"batchCreated_other": "{{count}} events created",
|
||||
"batchPartial": "{{ok}} created, {{failed}} failed",
|
||||
"batchFailed": "Could not create events"
|
||||
},
|
||||
"comment": {
|
||||
"created": "Comment added",
|
||||
@@ -476,5 +523,21 @@
|
||||
"runStarted": "Agent run started",
|
||||
"runError": "Failed to start agent"
|
||||
}
|
||||
},
|
||||
"date": {
|
||||
"keyword": {
|
||||
"today": ["today"],
|
||||
"tomorrow": ["tomorrow", "tmrw"],
|
||||
"yesterday": ["yesterday"],
|
||||
"weekdays": [
|
||||
["sun", "sunday"],
|
||||
["mon", "monday"],
|
||||
["tue", "tuesday"],
|
||||
["wed", "wednesday"],
|
||||
["thu", "thursday"],
|
||||
["fri", "friday"],
|
||||
["sat", "saturday"]
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,8 @@
|
||||
"priority": "Prioridad",
|
||||
"createdDate": "Fecha de creación",
|
||||
"newTask": "Nueva tarea",
|
||||
"newTaskDescription": "Anota qué hay que hacer. Define las propiedades abajo o más tarde.",
|
||||
"editTaskDescription": "Actualiza los detalles, propiedades y adjuntos de la tarea.",
|
||||
"noTasksFound": "No se encontraron tareas",
|
||||
"noTasksDescription": "Crea una nueva tarea para empezar o ajusta tus filtros.",
|
||||
"taskTitle": "Título de la tarea",
|
||||
@@ -309,16 +311,34 @@
|
||||
"zoomIn": "Acercar",
|
||||
"zoomOut": "Alejar",
|
||||
"addEventTitle": "Añadir evento de línea de tiempo",
|
||||
"addEventDescription": "Añade uno o más eventos al proyecto y luego guárdalos todos a la vez.",
|
||||
"editEventTitle": "Editar evento de línea de tiempo",
|
||||
"eventTitlePlaceholder": "Título del evento",
|
||||
"pickDate": "Selecciona una fecha",
|
||||
"pickDateRange": "Selecciona un rango de fechas",
|
||||
"pickStart": "Fecha de inicio",
|
||||
"pickEnd": "Fecha de fin",
|
||||
"selectProjectOptional": "Seleccionar proyecto (opcional)",
|
||||
"addAnother": "Añadir otro",
|
||||
"markAsDone": "Marcar como hecho",
|
||||
"markAsToDo": "Marcar como pendiente",
|
||||
"editEvent": "Editar evento",
|
||||
"deleteEvent": "Eliminar evento"
|
||||
"deleteEvent": "Eliminar evento",
|
||||
"endBeforeStart": "El fin debe ser posterior al inicio",
|
||||
"dateInvalid": "Fecha no reconocida",
|
||||
"batchCreated_one": "1 evento creado",
|
||||
"batchCreated_other": "{{count}} eventos creados",
|
||||
"batchPartial": "{{ok}} creados, {{failed}} fallidos",
|
||||
"batchFailed": "No se pudieron crear los eventos",
|
||||
"staged_one": "1 evento en cola",
|
||||
"staged_other": "{{count}} eventos en cola",
|
||||
"emptyStagedHint": "Escribe un título, elige una fecha, pulsa Intro",
|
||||
"editRow": "Editar",
|
||||
"removeRow": "Quitar",
|
||||
"projectLocked": "Proyecto bloqueado tras el primer evento",
|
||||
"confirmCloseStaged": "¿Descartar {{count}} eventos en cola?",
|
||||
"saveAll": "Guardar {{count}}",
|
||||
"update": "Actualizar"
|
||||
},
|
||||
"projects": {
|
||||
"noProjectSelected": "Ningún proyecto seleccionado",
|
||||
@@ -373,7 +393,30 @@
|
||||
"untitledNote": "Nota sin título",
|
||||
"noNotesYet": "No hay notas.",
|
||||
"noNotesYetDescription": "Haz clic en \"+ Añadir\" para crear tu primera nota.",
|
||||
"overview": "Resumen"
|
||||
"overview": "Resumen",
|
||||
"folder": {
|
||||
"title": "Archivos",
|
||||
"linkCta": "Vincular carpeta",
|
||||
"browse": "Explorar",
|
||||
"rescan": "Reescanear",
|
||||
"unlink": "Desvincular",
|
||||
"filesCount_one": "{{count}} archivo",
|
||||
"filesCount_other": "{{count}} archivos",
|
||||
"lastScanned": "último escaneo {{relative}}",
|
||||
"scanning": "indexando {{processed}}/{{total}}",
|
||||
"scanFailed": "Error de escaneo",
|
||||
"empty": {
|
||||
"title": "Vincula una carpeta al proyecto",
|
||||
"description": "Conecta una carpeta local para que los agentes de IA puedan leer sus archivos al responder preguntas sobre este proyecto.",
|
||||
"cta": "Elegir carpeta…"
|
||||
},
|
||||
"errors": {
|
||||
"tooBig": "Carpeta demasiado grande para el plan {{tier}} — máx. {{count}} archivos",
|
||||
"monthlyExhausted": "Presupuesto mensual de tokens agotado (se renueva el {{date}})",
|
||||
"notFound": "Carpeta no encontrada: {{path}}"
|
||||
},
|
||||
"webOnlyTooltip": "La vinculación de carpetas está disponible en la app de escritorio"
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"title": "Agentes",
|
||||
@@ -454,7 +497,11 @@
|
||||
"updateError": "Error al actualizar el evento",
|
||||
"deleted": "Evento eliminado",
|
||||
"deleteError": "Error al eliminar el evento",
|
||||
"historyError": "No se pudo deshacer — el evento puede haber cambiado."
|
||||
"historyError": "No se pudo deshacer — el evento puede haber cambiado.",
|
||||
"batchCreated_one": "1 evento creado",
|
||||
"batchCreated_other": "{{count}} eventos creados",
|
||||
"batchPartial": "{{ok}} creados, {{failed}} fallidos",
|
||||
"batchFailed": "No se pudieron crear los eventos"
|
||||
},
|
||||
"comment": {
|
||||
"created": "Comentario agregado",
|
||||
@@ -476,5 +523,21 @@
|
||||
"runStarted": "Ejecución del agente iniciada",
|
||||
"runError": "Error al iniciar el agente"
|
||||
}
|
||||
},
|
||||
"date": {
|
||||
"keyword": {
|
||||
"today": ["hoy"],
|
||||
"tomorrow": ["mañana", "manana"],
|
||||
"yesterday": ["ayer"],
|
||||
"weekdays": [
|
||||
["dom", "domingo"],
|
||||
["lun", "lunes"],
|
||||
["mar", "martes"],
|
||||
["mié", "mie", "miércoles", "miercoles"],
|
||||
["jue", "jueves"],
|
||||
["vie", "viernes"],
|
||||
["sáb", "sab", "sábado", "sabado"]
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,8 @@
|
||||
"priority": "Priorité",
|
||||
"createdDate": "Date de création",
|
||||
"newTask": "Nouvelle tâche",
|
||||
"newTaskDescription": "Notez ce qu'il faut faire. Définissez les propriétés ci-dessous ou plus tard.",
|
||||
"editTaskDescription": "Mettez à jour les détails, propriétés et pièces jointes de la tâche.",
|
||||
"noTasksFound": "Aucune tâche trouvée",
|
||||
"noTasksDescription": "Créez une nouvelle tâche pour commencer ou ajustez vos filtres.",
|
||||
"taskTitle": "Titre de la tâche",
|
||||
@@ -309,16 +311,34 @@
|
||||
"zoomIn": "Zoom avant",
|
||||
"zoomOut": "Zoom arrière",
|
||||
"addEventTitle": "Ajouter un événement",
|
||||
"addEventDescription": "Ajoutez un ou plusieurs événements au projet, puis enregistrez-les tous ensemble.",
|
||||
"editEventTitle": "Modifier l'événement",
|
||||
"eventTitlePlaceholder": "Titre de l'événement",
|
||||
"pickDate": "Choisir une date",
|
||||
"pickDateRange": "Choisir une plage de dates",
|
||||
"pickStart": "Date de début",
|
||||
"pickEnd": "Date de fin",
|
||||
"selectProjectOptional": "Sélectionner un projet (optionnel)",
|
||||
"addAnother": "Ajouter un autre",
|
||||
"markAsDone": "Marquer comme fait",
|
||||
"markAsToDo": "Marquer comme à faire",
|
||||
"editEvent": "Modifier l'événement",
|
||||
"deleteEvent": "Supprimer l'événement"
|
||||
"deleteEvent": "Supprimer l'événement",
|
||||
"endBeforeStart": "La fin doit être après le début",
|
||||
"dateInvalid": "Date non reconnue",
|
||||
"batchCreated_one": "1 événement créé",
|
||||
"batchCreated_other": "{{count}} événements créés",
|
||||
"batchPartial": "{{ok}} créés, {{failed}} échoués",
|
||||
"batchFailed": "Impossible de créer les événements",
|
||||
"staged_one": "1 événement en attente",
|
||||
"staged_other": "{{count}} événements en attente",
|
||||
"emptyStagedHint": "Saisissez un titre, choisissez une date, appuyez sur Entrée",
|
||||
"editRow": "Modifier",
|
||||
"removeRow": "Retirer",
|
||||
"projectLocked": "Projet verrouillé après le premier événement",
|
||||
"confirmCloseStaged": "Abandonner {{count}} événements en attente ?",
|
||||
"saveAll": "Enregistrer {{count}}",
|
||||
"update": "Mettre à jour"
|
||||
},
|
||||
"projects": {
|
||||
"noProjectSelected": "Aucun projet sélectionné",
|
||||
@@ -373,7 +393,30 @@
|
||||
"untitledNote": "Note sans titre",
|
||||
"noNotesYet": "Aucune note.",
|
||||
"noNotesYetDescription": "Cliquez sur \"+ Ajouter\" pour créer votre première note.",
|
||||
"overview": "Vue d'ensemble"
|
||||
"overview": "Vue d'ensemble",
|
||||
"folder": {
|
||||
"title": "Fichiers",
|
||||
"linkCta": "Lier un dossier",
|
||||
"browse": "Parcourir",
|
||||
"rescan": "Rescanner",
|
||||
"unlink": "Dissocier",
|
||||
"filesCount_one": "{{count}} fichier",
|
||||
"filesCount_other": "{{count}} fichiers",
|
||||
"lastScanned": "dernier scan {{relative}}",
|
||||
"scanning": "indexation {{processed}}/{{total}}",
|
||||
"scanFailed": "Échec du scan",
|
||||
"empty": {
|
||||
"title": "Lier un dossier au projet",
|
||||
"description": "Connectez un dossier local pour que les agents IA puissent lire ses fichiers lorsqu'ils répondent aux questions sur ce projet.",
|
||||
"cta": "Choisir un dossier…"
|
||||
},
|
||||
"errors": {
|
||||
"tooBig": "Dossier trop grand pour le plan {{tier}} — max {{count}} fichiers",
|
||||
"monthlyExhausted": "Budget mensuel de tokens épuisé (réinitialisé le {{date}})",
|
||||
"notFound": "Dossier introuvable : {{path}}"
|
||||
},
|
||||
"webOnlyTooltip": "La liaison de dossiers est disponible dans l'application bureau"
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"title": "Agents",
|
||||
@@ -454,7 +497,11 @@
|
||||
"updateError": "Impossible de mettre à jour l'événement",
|
||||
"deleted": "Événement supprimé",
|
||||
"deleteError": "Impossible de supprimer l'événement",
|
||||
"historyError": "Impossible d'annuler — l'événement a peut-être changé."
|
||||
"historyError": "Impossible d'annuler — l'événement a peut-être changé.",
|
||||
"batchCreated_one": "1 événement créé",
|
||||
"batchCreated_other": "{{count}} événements créés",
|
||||
"batchPartial": "{{ok}} créés, {{failed}} échoués",
|
||||
"batchFailed": "Impossible de créer les événements"
|
||||
},
|
||||
"comment": {
|
||||
"created": "Commentaire ajouté",
|
||||
@@ -476,5 +523,21 @@
|
||||
"runStarted": "Exécution de l'agent lancée",
|
||||
"runError": "Impossible de lancer l'agent"
|
||||
}
|
||||
},
|
||||
"date": {
|
||||
"keyword": {
|
||||
"today": ["aujourd'hui", "auj"],
|
||||
"tomorrow": ["demain"],
|
||||
"yesterday": ["hier"],
|
||||
"weekdays": [
|
||||
["dim", "dimanche"],
|
||||
["lun", "lundi"],
|
||||
["mar", "mardi"],
|
||||
["mer", "mercredi"],
|
||||
["jeu", "jeudi"],
|
||||
["ven", "vendredi"],
|
||||
["sam", "samedi"]
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -41,6 +41,8 @@
|
||||
"priority": "Priorità",
|
||||
"createdDate": "Data creazione",
|
||||
"newTask": "Nuova attività",
|
||||
"newTaskDescription": "Annota cosa va fatto. Imposta le proprietà sotto o aggiornale dopo.",
|
||||
"editTaskDescription": "Aggiorna dettagli, proprietà e allegati dell'attività.",
|
||||
"noTasksFound": "Nessuna attività trovata",
|
||||
"noTasksDescription": "Crea una nuova attività per iniziare o modifica i filtri.",
|
||||
"taskTitle": "Titolo attività",
|
||||
@@ -309,16 +311,34 @@
|
||||
"zoomIn": "Ingrandisci",
|
||||
"zoomOut": "Riduci",
|
||||
"addEventTitle": "Aggiungi evento timeline",
|
||||
"addEventDescription": "Aggiungi uno o più eventi al progetto, poi salvali tutti insieme.",
|
||||
"editEventTitle": "Modifica evento timeline",
|
||||
"eventTitlePlaceholder": "Titolo evento",
|
||||
"pickDate": "Seleziona una data",
|
||||
"pickDateRange": "Seleziona un intervallo di date",
|
||||
"pickStart": "Data inizio",
|
||||
"pickEnd": "Data fine",
|
||||
"selectProjectOptional": "Seleziona progetto (opzionale)",
|
||||
"addAnother": "Aggiungi un altro",
|
||||
"markAsDone": "Segna come fatto",
|
||||
"markAsToDo": "Segna come da fare",
|
||||
"editEvent": "Modifica evento",
|
||||
"deleteEvent": "Elimina evento"
|
||||
"deleteEvent": "Elimina evento",
|
||||
"endBeforeStart": "La fine deve essere dopo l'inizio",
|
||||
"dateInvalid": "Data non riconosciuta",
|
||||
"batchCreated_one": "1 evento creato",
|
||||
"batchCreated_other": "{{count}} eventi creati",
|
||||
"batchPartial": "{{ok}} creati, {{failed}} falliti",
|
||||
"batchFailed": "Impossibile creare gli eventi",
|
||||
"staged_one": "1 evento in coda",
|
||||
"staged_other": "{{count}} eventi in coda",
|
||||
"emptyStagedHint": "Inserisci un titolo, imposta una data, premi Invio",
|
||||
"editRow": "Modifica",
|
||||
"removeRow": "Rimuovi",
|
||||
"projectLocked": "Progetto bloccato dopo il primo evento",
|
||||
"confirmCloseStaged": "Eliminare {{count}} eventi in coda?",
|
||||
"saveAll": "Salva {{count}}",
|
||||
"update": "Aggiorna"
|
||||
},
|
||||
"projects": {
|
||||
"noProjectSelected": "Nessun progetto selezionato",
|
||||
@@ -373,7 +393,30 @@
|
||||
"untitledNote": "Nota senza titolo",
|
||||
"noNotesYet": "Nessuna nota.",
|
||||
"noNotesYetDescription": "Clicca \"+ Aggiungi\" per creare la tua prima nota.",
|
||||
"overview": "Panoramica"
|
||||
"overview": "Panoramica",
|
||||
"folder": {
|
||||
"title": "File",
|
||||
"linkCta": "Collega cartella",
|
||||
"browse": "Sfoglia",
|
||||
"rescan": "Riscansiona",
|
||||
"unlink": "Scollega",
|
||||
"filesCount_one": "{{count}} file",
|
||||
"filesCount_other": "{{count}} file",
|
||||
"lastScanned": "ultima scansione {{relative}}",
|
||||
"scanning": "indicizzazione {{processed}}/{{total}}",
|
||||
"scanFailed": "Scansione fallita",
|
||||
"empty": {
|
||||
"title": "Collega una cartella al progetto",
|
||||
"description": "Connetti una cartella locale in modo che gli agenti AI possano leggerne i file quando rispondono alle domande su questo progetto.",
|
||||
"cta": "Scegli cartella…"
|
||||
},
|
||||
"errors": {
|
||||
"tooBig": "Cartella troppo grande per il piano {{tier}} — max {{count}} file",
|
||||
"monthlyExhausted": "Budget mensile token esaurito (si rinnova il {{date}})",
|
||||
"notFound": "Cartella non trovata: {{path}}"
|
||||
},
|
||||
"webOnlyTooltip": "Il collegamento cartelle è disponibile nell'app desktop"
|
||||
}
|
||||
},
|
||||
"agents": {
|
||||
"title": "Agenti",
|
||||
@@ -454,7 +497,11 @@
|
||||
"updateError": "Impossibile aggiornare l'evento",
|
||||
"deleted": "Evento eliminato",
|
||||
"deleteError": "Impossibile eliminare l'evento",
|
||||
"historyError": "Impossibile annullare — l'evento potrebbe essere cambiato."
|
||||
"historyError": "Impossibile annullare — l'evento potrebbe essere cambiato.",
|
||||
"batchCreated_one": "1 evento creato",
|
||||
"batchCreated_other": "{{count}} eventi creati",
|
||||
"batchPartial": "{{ok}} creati, {{failed}} falliti",
|
||||
"batchFailed": "Impossibile creare gli eventi"
|
||||
},
|
||||
"comment": {
|
||||
"created": "Commento aggiunto",
|
||||
@@ -476,5 +523,21 @@
|
||||
"runStarted": "Esecuzione agente avviata",
|
||||
"runError": "Impossibile avviare l'agente"
|
||||
}
|
||||
},
|
||||
"date": {
|
||||
"keyword": {
|
||||
"today": ["oggi"],
|
||||
"tomorrow": ["domani"],
|
||||
"yesterday": ["ieri"],
|
||||
"weekdays": [
|
||||
["dom", "domenica"],
|
||||
["lun", "lunedì", "lunedi"],
|
||||
["mar", "martedì", "martedi"],
|
||||
["mer", "mercoledì", "mercoledi"],
|
||||
["gio", "giovedì", "giovedi"],
|
||||
["ven", "venerdì", "venerdi"],
|
||||
["sab", "sabato"]
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -52,6 +52,9 @@ export const ToolCallActionSchema = z.enum([
|
||||
'list_directory',
|
||||
'read_file_content',
|
||||
'get_file_metadata',
|
||||
'read_project_folder_manifest',
|
||||
'read_project_folder_file',
|
||||
'list_projects_with_folder_manifests',
|
||||
]);
|
||||
export type ToolCallAction = z.infer<typeof ToolCallActionSchema>;
|
||||
|
||||
@@ -205,7 +208,8 @@ export const WsStreamEndSchema = z.object({
|
||||
mutations: z.union([
|
||||
z.record(z.string(), z.unknown()),
|
||||
z.array(z.unknown()),
|
||||
]).optional(),
|
||||
]).nullish(),
|
||||
error: z.string().nullish(),
|
||||
});
|
||||
export type WsStreamEnd = z.infer<typeof WsStreamEndSchema>;
|
||||
|
||||
@@ -278,6 +282,31 @@ export const WsRunCompleteSchema = z.object({
|
||||
});
|
||||
export type WsRunComplete = z.infer<typeof WsRunCompleteSchema>;
|
||||
|
||||
export const WsIndexFileResultSchema = z.object({
|
||||
type: z.literal('index_file_result'),
|
||||
sessionId: z.string(),
|
||||
relPath: z.string(),
|
||||
summary: z.string().nullable().optional(),
|
||||
tokensUsed: z.number().int().nonnegative().default(0),
|
||||
error: z.string().optional(),
|
||||
});
|
||||
export type WsIndexFileResult = z.infer<typeof WsIndexFileResultSchema>;
|
||||
|
||||
export const WsIndexSessionProgressSchema = z.object({
|
||||
type: z.literal('index_session_progress'),
|
||||
sessionId: z.string(),
|
||||
processed: z.number().int().nonnegative(),
|
||||
total: z.number().int().nonnegative(),
|
||||
});
|
||||
export type WsIndexSessionProgress = z.infer<typeof WsIndexSessionProgressSchema>;
|
||||
|
||||
export const WsIndexSessionDoneSchema = z.object({
|
||||
type: z.literal('index_session_done'),
|
||||
sessionId: z.string(),
|
||||
status: z.enum(['completed', 'cancelled', 'quota_exceeded', 'error']),
|
||||
});
|
||||
export type WsIndexSessionDone = z.infer<typeof WsIndexSessionDoneSchema>;
|
||||
|
||||
export const WsServerFrameSchema = z.discriminatedUnion('type', [
|
||||
WsToolCallSchema,
|
||||
WsPingSchema,
|
||||
@@ -287,6 +316,9 @@ export const WsServerFrameSchema = z.discriminatedUnion('type', [
|
||||
WsFloatingDomainSchema,
|
||||
WsJourneyReplySchema,
|
||||
WsRunCompleteSchema,
|
||||
WsIndexFileResultSchema,
|
||||
WsIndexSessionProgressSchema,
|
||||
WsIndexSessionDoneSchema,
|
||||
]);
|
||||
export type WsServerFrame = z.infer<typeof WsServerFrameSchema>;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user