@knotpad/app 0.1.5 → 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/app/(app)/calendar/page.tsx +57 -0
- package/app/(app)/error.tsx +35 -0
- package/app/(app)/graph/page.tsx +32 -0
- package/app/(app)/guide/page.tsx +21 -0
- package/app/(app)/kanban/loading.tsx +24 -0
- package/app/(app)/kanban/page.tsx +59 -0
- package/app/(app)/layout.tsx +122 -0
- package/app/(app)/list/loading.tsx +21 -0
- package/app/(app)/list/page.tsx +137 -0
- package/app/(app)/loading.tsx +18 -0
- package/app/(app)/notes/[noteId]/page.tsx +84 -0
- package/app/(app)/notes/layout.tsx +30 -0
- package/app/(app)/notes/page.tsx +39 -0
- package/app/(app)/page.tsx +5 -0
- package/app/(app)/settings/agent-token/page.tsx +59 -0
- package/app/(app)/settings/backup/page.tsx +49 -0
- package/app/(app)/settings/billing/page.tsx +53 -0
- package/app/(app)/settings/calendar/page.tsx +41 -0
- package/app/(app)/settings/layout.test.tsx +39 -0
- package/app/(app)/settings/layout.tsx +71 -0
- package/app/(app)/settings/page.tsx +4 -0
- package/app/(app)/settings/security/page.tsx +43 -0
- package/app/(app)/settings/team/page.tsx +74 -0
- package/app/(app)/settings/workspace/page.tsx +27 -0
- package/app/(app)/tasks/[taskId]/page.tsx +79 -0
- package/app/(auth)/forgot-password/page.tsx +106 -0
- package/app/(auth)/guest/page.tsx +56 -0
- package/app/(auth)/layout.tsx +13 -0
- package/app/(auth)/login/page.tsx +14 -0
- package/app/(auth)/register/page.tsx +193 -0
- package/app/(auth)/reset-password/page.tsx +138 -0
- package/app/api/account/claim/route.tsx +135 -0
- package/app/api/admin/backfill-encryption/route.tsx +43 -0
- package/app/api/admin/license/route.tsx +42 -0
- package/app/api/auth/2fa/route.tsx +148 -0
- package/app/api/auth/[...nextauth]/route.tsx +3 -0
- package/app/api/auth/change-password/route.tsx +61 -0
- package/app/api/auth/check-2fa/route.tsx +19 -0
- package/app/api/auth/forgot-password/route.tsx +65 -0
- package/app/api/auth/reset-password/route.tsx +52 -0
- package/app/api/auth/verify-2fa/route.tsx +88 -0
- package/app/api/backup/download/db/route.ts +29 -0
- package/app/api/backup/download/notes/route.ts +25 -0
- package/app/api/backup/settings/route.ts +92 -0
- package/app/api/billing/checkout/route.tsx +81 -0
- package/app/api/billing/migrate/route.tsx +163 -0
- package/app/api/billing/portal/route.tsx +24 -0
- package/app/api/billing/setup-intent/route.tsx +55 -0
- package/app/api/billing/status/route.tsx +36 -0
- package/app/api/billing/subscribe/route.tsx +85 -0
- package/app/api/billing/webhook/route.tsx +199 -0
- package/app/api/calendar-feeds/[feedId]/route.tsx +67 -0
- package/app/api/calendar-feeds/[feedId]/sync/route.tsx +37 -0
- package/app/api/calendar-feeds/events/route.tsx +82 -0
- package/app/api/calendar-feeds/route.tsx +52 -0
- package/app/api/calendar-feeds/sync-all/route.tsx +34 -0
- package/app/api/cron/calendar-feeds/route.tsx +31 -0
- package/app/api/cron/stale-tasks/route.tsx +51 -0
- package/app/api/cron/sync/route.tsx +34 -0
- package/app/api/devices/[deviceId]/route.tsx +25 -0
- package/app/api/devices/route.tsx +41 -0
- package/app/api/export/route.tsx +40 -0
- package/app/api/feedback/route.tsx +54 -0
- package/app/api/folders/[folderId]/route.tsx +51 -0
- package/app/api/folders/route.tsx +37 -0
- package/app/api/graph/route.tsx +242 -0
- package/app/api/guest/route.tsx +58 -0
- package/app/api/health/route.tsx +10 -0
- package/app/api/holidays/countries/route.tsx +14 -0
- package/app/api/holidays/route.tsx +49 -0
- package/app/api/holidays/states/route.tsx +21 -0
- package/app/api/invites/[token]/route.tsx +131 -0
- package/app/api/invites/route.tsx +74 -0
- package/app/api/mcp/generate-token/route.tsx +55 -0
- package/app/api/mcp/revoke-token/[tokenId]/route.tsx +30 -0
- package/app/api/mcp/update-alias/[tokenId]/route.tsx +22 -0
- package/app/api/notes/[noteId]/export/route.tsx +45 -0
- package/app/api/notes/[noteId]/route.tsx +360 -0
- package/app/api/notes/route.tsx +112 -0
- package/app/api/notifications/route.tsx +44 -0
- package/app/api/register/route.tsx +67 -0
- package/app/api/restore/route.tsx +148 -0
- package/app/api/sync/conflicts/[conflictId]/route.tsx +134 -0
- package/app/api/sync/conflicts/route.tsx +48 -0
- package/app/api/sync/status/route.tsx +49 -0
- package/app/api/sync/trigger/route.tsx +15 -0
- package/app/api/tasks/[taskId]/detail/route.tsx +68 -0
- package/app/api/tasks/[taskId]/route.tsx +259 -0
- package/app/api/tasks/bulk/route.tsx +133 -0
- package/app/api/tasks/route.tsx +36 -0
- package/app/api/workspace/active/route.tsx +39 -0
- package/app/api/workspace/create-team/route.tsx +42 -0
- package/app/api/workspace/kanban-statuses/route.tsx +71 -0
- package/app/api/workspace/members/[memberId]/route.tsx +69 -0
- package/app/api/workspace/route.tsx +24 -0
- package/app/download/page.tsx +170 -0
- package/app/favicon.ico +0 -0
- package/app/generated/prisma/client.d.ts +1 -0
- package/app/generated/prisma/client.js +5 -0
- package/app/generated/prisma/default.d.ts +1 -0
- package/app/generated/prisma/default.js +5 -0
- package/app/generated/prisma/edge.d.ts +1 -0
- package/app/generated/prisma/edge.js +497 -0
- package/app/generated/prisma/index-browser.js +523 -0
- package/app/generated/prisma/index.d.ts +46376 -0
- package/app/generated/prisma/index.js +497 -0
- package/app/generated/prisma/package.json +144 -0
- package/app/generated/prisma/query_compiler_fast_bg.js +2 -0
- package/app/generated/prisma/query_compiler_fast_bg.wasm +0 -0
- package/app/generated/prisma/query_compiler_fast_bg.wasm-base64.js +2 -0
- package/app/generated/prisma/runtime/client.d.ts +3386 -0
- package/app/generated/prisma/runtime/client.js +86 -0
- package/app/generated/prisma/runtime/index-browser.d.ts +90 -0
- package/app/generated/prisma/runtime/index-browser.js +6 -0
- package/app/generated/prisma/runtime/wasm-compiler-edge.js +76 -0
- package/app/generated/prisma/schema.prisma +456 -0
- package/app/generated/prisma/wasm-edge-light-loader.mjs +5 -0
- package/app/generated/prisma/wasm-worker-loader.mjs +5 -0
- package/app/globals.css +54 -0
- package/app/invite/[token]/page.tsx +52 -0
- package/app/layout.tsx +90 -0
- package/app/mcp/route.tsx +430 -0
- package/app/opengraph-image.tsx +120 -0
- package/app/page.tsx +398 -0
- package/app/privacy/page.tsx +69 -0
- package/app/robots.tsx +25 -0
- package/app/sitemap.tsx +36 -0
- package/app/terms/page.tsx +69 -0
- package/app/upgrade/page.tsx +75 -0
- package/auth.config.ts +33 -0
- package/auth.ts +79 -0
- package/bin/brief.js +224 -0
- package/components/auth/login-form.tsx +302 -0
- package/components/auth/password-checklist.tsx +31 -0
- package/components/auth/password-input.tsx +36 -0
- package/components/auth/switch-account-button.test.tsx +22 -0
- package/components/auth/switch-account-button.tsx +19 -0
- package/components/auth/two-factor-input.tsx +116 -0
- package/components/billing/billing-dashboard.tsx +265 -0
- package/components/billing/card-form.tsx +210 -0
- package/components/billing/claim-account-form.tsx +99 -0
- package/components/branding/app-logo.test.tsx +20 -0
- package/components/branding/app-logo.tsx +25 -0
- package/components/calendar/calendar-agenda.tsx +150 -0
- package/components/calendar/calendar-drag.test.tsx +177 -0
- package/components/calendar/calendar-grid.tsx +357 -0
- package/components/calendar/calendar-hooks.test.tsx +27 -0
- package/components/calendar/calendar-hooks.ts +351 -0
- package/components/calendar/calendar-toolbar.test.tsx +68 -0
- package/components/calendar/calendar-toolbar.tsx +291 -0
- package/components/calendar/calendar-types.ts +148 -0
- package/components/calendar/calendar-view.test.tsx +295 -0
- package/components/calendar/calendar-view.tsx +307 -0
- package/components/calendar/day-detail-popover.tsx +174 -0
- package/components/calendar/task-chip.tsx +86 -0
- package/components/command/command-palette.test.tsx +33 -0
- package/components/command/command-palette.tsx +310 -0
- package/components/download-cta.tsx +87 -0
- package/components/feedback/feedback-popup.tsx +207 -0
- package/components/graph/graph-draw.ts +337 -0
- package/components/graph/graph-overlays.tsx +160 -0
- package/components/graph/graph-page.test.tsx +131 -0
- package/components/graph/graph-page.tsx +263 -0
- package/components/graph/graph-types.ts +47 -0
- package/components/graph/graph-view.tsx +322 -0
- package/components/guide/guide-view.tsx +522 -0
- package/components/kanban/kanban-board.test.tsx +128 -0
- package/components/kanban/kanban-board.tsx +361 -0
- package/components/kanban/kanban-card-menu.tsx +102 -0
- package/components/kanban/kanban-card.tsx +227 -0
- package/components/kanban/kanban-column.tsx +49 -0
- package/components/kanban/kanban-status-context.tsx +28 -0
- package/components/landing/calendar-sandbox.test.tsx +15 -0
- package/components/landing/calendar-sandbox.tsx +107 -0
- package/components/landing/graph-sandbox.test.tsx +27 -0
- package/components/landing/graph-sandbox.tsx +80 -0
- package/components/landing/kanban-sandbox.test.tsx +24 -0
- package/components/landing/kanban-sandbox.tsx +101 -0
- package/components/landing/landing-showcase.test.tsx +21 -0
- package/components/landing/landing-showcase.tsx +54 -0
- package/components/landing/list-sandbox.tsx +86 -0
- package/components/landing/mock-workspace.ts +168 -0
- package/components/landing/notes-sandbox.test.tsx +14 -0
- package/components/landing/notes-sandbox.tsx +88 -0
- package/components/layout/app-shell.tsx +83 -0
- package/components/layout/backup-scheduler.tsx +122 -0
- package/components/layout/bottom-nav.tsx +43 -0
- package/components/layout/icon-bar.test.tsx +29 -0
- package/components/layout/icon-bar.tsx +118 -0
- package/components/layout/mobile-top-bar.tsx +68 -0
- package/components/layout/notes-panel-folder.tsx +127 -0
- package/components/layout/notes-panel-note-item.tsx +140 -0
- package/components/layout/notes-panel-task-tab.tsx +63 -0
- package/components/layout/notes-panel-types.ts +44 -0
- package/components/layout/notes-panel.tsx +476 -0
- package/components/layout/notification-bell.tsx +251 -0
- package/components/layout/paywall-screen.tsx +41 -0
- package/components/layout/pro-banner.tsx +76 -0
- package/components/layout/sw-register.tsx +27 -0
- package/components/layout/workspace-switcher.tsx +90 -0
- package/components/notes/mobile-bottom-sheet.tsx +99 -0
- package/components/notes/note-editor-context-menu.tsx +47 -0
- package/components/notes/note-editor-dom.ts +33 -0
- package/components/notes/note-editor-dropdowns.tsx +484 -0
- package/components/notes/note-editor-hooks.ts +692 -0
- package/components/notes/note-editor-keyboard.ts +305 -0
- package/components/notes/note-editor-overlay.tsx +90 -0
- package/components/notes/note-editor.test.tsx +372 -0
- package/components/notes/note-editor.tsx +662 -0
- package/components/notes/note-preview-pane.tsx +156 -0
- package/components/notes/note-tabs.tsx +120 -0
- package/components/notes/note-types.tsx +157 -0
- package/components/settings/accept-invite.tsx +108 -0
- package/components/settings/agent-token-settings.tsx +369 -0
- package/components/settings/backup-restore-settings.test.tsx +25 -0
- package/components/settings/backup-restore-settings.tsx +327 -0
- package/components/settings/calendar-feeds-settings.tsx +489 -0
- package/components/settings/calendar-general-settings.tsx +174 -0
- package/components/settings/confirm-danger-action.test.tsx +215 -0
- package/components/settings/confirm-danger-action.tsx +65 -0
- package/components/settings/security-settings.tsx +252 -0
- package/components/settings/settings-guidance.test.tsx +98 -0
- package/components/settings/team-settings.tsx +319 -0
- package/components/settings/two-factor-auth.tsx +296 -0
- package/components/settings/workspace-settings-client.tsx +363 -0
- package/components/settings/workspace-settings-form.tsx +73 -0
- package/components/sync/conflict-viewer.tsx +247 -0
- package/components/sync/sync-indicator.tsx +171 -0
- package/components/tasks/snippet-thread.tsx +119 -0
- package/components/tasks/status-dot.tsx +47 -0
- package/components/tasks/task-badge.tsx +43 -0
- package/components/tasks/task-detail.test.tsx +187 -0
- package/components/tasks/task-detail.tsx +458 -0
- package/components/tasks/task-list-filters.test.tsx +75 -0
- package/components/tasks/task-list-filters.tsx +163 -0
- package/components/tasks/task-list-types.ts +20 -0
- package/components/tasks/task-list.test.tsx +175 -0
- package/components/tasks/task-list.tsx +481 -0
- package/components/tasks/task-row.tsx +85 -0
- package/components/tasks/task-table-row.tsx +259 -0
- package/components/ui/skeleton.tsx +3 -0
- package/components/ui/toast.test.tsx +42 -0
- package/components/ui/toast.tsx +70 -0
- package/instrumentation.tsx +23 -0
- package/lib/api-error.ts +50 -0
- package/lib/backup/backup-runner.test.ts +32 -0
- package/lib/backup/backup-runner.ts +19 -0
- package/lib/backup/backup-schedule.test.ts +23 -0
- package/lib/backup/backup-schedule.ts +55 -0
- package/lib/backup/backup-settings.test.ts +30 -0
- package/lib/backup/backup-settings.ts +27 -0
- package/lib/backup/export-notes-zip.test.ts +26 -0
- package/lib/backup/export-notes-zip.ts +82 -0
- package/lib/backup/export-workspace-backup.test.ts +17 -0
- package/lib/backup/export-workspace-backup.ts +77 -0
- package/lib/backup/restore-workspace-from-export.test.ts +18 -0
- package/lib/backup/restore-workspace-from-export.ts +183 -0
- package/lib/backup/types.ts +14 -0
- package/lib/brand-icons.ts +1 -0
- package/lib/calendar-feed-crypto.ts +38 -0
- package/lib/calendar-feed.ts +239 -0
- package/lib/client/online-status.ts +47 -0
- package/lib/conflict-resolver.test.ts +57 -0
- package/lib/conflict-resolver.ts +240 -0
- package/lib/db-init.ts +79 -0
- package/lib/email.ts +159 -0
- package/lib/encryption.test.ts +41 -0
- package/lib/encryption.ts +98 -0
- package/lib/extract-snippet.test.ts +123 -0
- package/lib/extract-snippet.ts +69 -0
- package/lib/kanban-status.ts +55 -0
- package/lib/license.ts +21 -0
- package/lib/limits.ts +31 -0
- package/lib/mcp-auth.test.ts +58 -0
- package/lib/mcp-auth.ts +65 -0
- package/lib/mcp-contract.test.ts +25 -0
- package/lib/mcp-contract.ts +210 -0
- package/lib/mcp-handler.ts +31 -0
- package/lib/mcp-url.test.ts +12 -0
- package/lib/mcp-url.ts +7 -0
- package/lib/mentions.test.ts +45 -0
- package/lib/mentions.ts +73 -0
- package/lib/note-crypto.ts +108 -0
- package/lib/note-sync.ts +201 -0
- package/lib/note-title.ts +93 -0
- package/lib/prisma.ts +193 -0
- package/lib/pro-flush.ts +292 -0
- package/lib/rate-limit.ts +57 -0
- package/lib/stripe.ts +38 -0
- package/lib/sync-worker.ts +388 -0
- package/lib/task-parser.test.ts +91 -0
- package/lib/task-parser.ts +81 -0
- package/lib/task-utils.ts +52 -0
- package/lib/use-is-electron.ts +19 -0
- package/lib/use-is-mobile.ts +22 -0
- package/lib/validation/calendar-feed.ts +31 -0
- package/lib/validation/note.ts +27 -0
- package/lib/validation/task.ts +26 -0
- package/lib/view-preferences.test.ts +54 -0
- package/lib/view-preferences.ts +28 -0
- package/lib/workspace.ts +66 -0
- package/next.config.ts +21 -0
- package/package.json +49 -3
- package/postcss.config.mjs +7 -0
- package/prisma/migrations/20260519021916_init/migration.sql +388 -0
- package/prisma/migrations/20260519061113_drop_sync_password/migration.sql +8 -0
- package/prisma/migrations/20260520065016_add_task_start_date/migration.sql +2 -0
- package/prisma/migrations/20260529010600_remove_encryption_fields/migration.sql +12 -0
- package/prisma/migrations/20260529020000_restore_encryption_salt/migration.sql +3 -0
- package/prisma/migrations/20260529030000_add_folders/migration.sql +17 -0
- package/prisma/migrations/20260605000000_deferred_fixes/migration.sql +31 -0
- package/prisma/migrations/20260605020806_add_pending_sync_to_note_and_task/migration.sql +5 -0
- package/prisma/migrations/20260605063634_add_stripe_webhook_event_sync_lock/migration.sql +14 -0
- package/prisma/migrations/20260605100000_add_prod_indexes/migration.sql +26 -0
- package/prisma/migrations/20260608081404_add_kanban_statuses/migration.sql +23 -0
- package/prisma/migrations/20260611032723_add_calendar_feeds/migration.sql +43 -0
- package/prisma/migrations/20260611040000_add_calendar_feed_color/migration.sql +2 -0
- package/prisma/migrations/20260611050000_add_task_priority/migration.sql +14 -0
- package/prisma/migrations/20260612060000_add_critical_priority/migration.sql +2 -0
- package/prisma/migrations/20260613090000_add_backup_settings/migration.sql +25 -0
- package/prisma/migrations/20260614160000_add_feedback/migration.sql +20 -0
- package/prisma/migrations/20260614210000_add_2fa/migration.sql +4 -0
- package/prisma/migrations/migration_lock.toml +3 -0
- package/prisma/schema.prisma +457 -0
- package/public/Logo_icon.svg +1 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/icon-192.png +0 -0
- package/public/icon-512.png +0 -0
- package/public/icon.svg +4 -0
- package/public/icon_dark.svg +1 -0
- package/public/knotpad_icon.svg +1 -0
- package/public/knotpad_logo_full.svg +1 -0
- package/public/manifest.json +14 -0
- package/public/next.svg +1 -0
- package/public/sw.js +137 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/tsconfig.json +35 -0
- package/brief.js +0 -311
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import Link from "next/link";
|
|
4
|
+
import { useKanbanStatuses } from "@/components/kanban/kanban-status-context";
|
|
5
|
+
import type { Task } from "./task-list-types";
|
|
6
|
+
|
|
7
|
+
export function TaskRow({
|
|
8
|
+
task,
|
|
9
|
+
status,
|
|
10
|
+
overdue,
|
|
11
|
+
updating,
|
|
12
|
+
detailHref,
|
|
13
|
+
onStatusChange,
|
|
14
|
+
}: {
|
|
15
|
+
task: Task;
|
|
16
|
+
status: string;
|
|
17
|
+
overdue: boolean;
|
|
18
|
+
updating: boolean;
|
|
19
|
+
detailHref: string;
|
|
20
|
+
onStatusChange: (id: string, s: string) => void;
|
|
21
|
+
}) {
|
|
22
|
+
const kanbanStatuses = useKanbanStatuses();
|
|
23
|
+
|
|
24
|
+
const assigneeLabel =
|
|
25
|
+
task.assigneeType === "AGENT"
|
|
26
|
+
? task.claimedByAlias
|
|
27
|
+
? `@agent:${task.claimedByAlias}`
|
|
28
|
+
: "@agent"
|
|
29
|
+
: task.assignee?.name
|
|
30
|
+
? `@${task.assignee.name}`
|
|
31
|
+
: null;
|
|
32
|
+
|
|
33
|
+
const dueDateStr = task.dueDate
|
|
34
|
+
? new Date(task.dueDate).toLocaleDateString(undefined, { month: "short", day: "numeric" })
|
|
35
|
+
: null;
|
|
36
|
+
|
|
37
|
+
return (
|
|
38
|
+
<div className={`flex items-start gap-3 rounded-md px-3 py-2.5 transition-colors group ${overdue ? "bg-red-950/20 hover:bg-red-950/30" : "hover:bg-zinc-800/50"}`}>
|
|
39
|
+
<div className="flex-1 min-w-0">
|
|
40
|
+
<Link href={detailHref} className="block group-hover:text-white transition-colors">
|
|
41
|
+
<p className={`text-sm truncate ${overdue ? "text-red-300" : "text-zinc-200"}`}>
|
|
42
|
+
{overdue && <span className="mr-1.5 text-red-400">!</span>}
|
|
43
|
+
{task.title}
|
|
44
|
+
</p>
|
|
45
|
+
<div className="mt-0.5 flex items-center gap-2 text-xs text-zinc-600">
|
|
46
|
+
<span className="truncate">{task.note.title}</span>
|
|
47
|
+
{dueDateStr && (
|
|
48
|
+
<>
|
|
49
|
+
<span>·</span>
|
|
50
|
+
<span className={overdue ? "text-red-400 font-medium" : "text-zinc-500"}>
|
|
51
|
+
{overdue ? "Due " : ""}{dueDateStr}
|
|
52
|
+
</span>
|
|
53
|
+
</>
|
|
54
|
+
)}
|
|
55
|
+
{assigneeLabel && (
|
|
56
|
+
<>
|
|
57
|
+
<span>·</span>
|
|
58
|
+
<span className={task.assigneeType === "AGENT" ? "text-blue-500" : "text-zinc-500"}>
|
|
59
|
+
{assigneeLabel}
|
|
60
|
+
</span>
|
|
61
|
+
</>
|
|
62
|
+
)}
|
|
63
|
+
</div>
|
|
64
|
+
</Link>
|
|
65
|
+
</div>
|
|
66
|
+
|
|
67
|
+
<select
|
|
68
|
+
value={status}
|
|
69
|
+
disabled={updating}
|
|
70
|
+
onChange={(e) => onStatusChange(task.id, e.target.value)}
|
|
71
|
+
className={`rounded border px-2 py-1 text-xs focus:outline-none ring-focus disabled:opacity-50 cursor-pointer ${
|
|
72
|
+
overdue
|
|
73
|
+
? "border-red-800/60 bg-red-950/40 text-red-300"
|
|
74
|
+
: "border-zinc-700 bg-zinc-900 text-zinc-400"
|
|
75
|
+
}`}
|
|
76
|
+
>
|
|
77
|
+
{kanbanStatuses.map((s) => (
|
|
78
|
+
<option key={s.key} value={s.key}>
|
|
79
|
+
{s.label}
|
|
80
|
+
</option>
|
|
81
|
+
))}
|
|
82
|
+
</select>
|
|
83
|
+
</div>
|
|
84
|
+
);
|
|
85
|
+
}
|
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { useState } from "react";
|
|
4
|
+
import Link from "next/link";
|
|
5
|
+
import type { Task } from "./task-list-types";
|
|
6
|
+
|
|
7
|
+
export function TaskTableRow({
|
|
8
|
+
task,
|
|
9
|
+
status,
|
|
10
|
+
overdue,
|
|
11
|
+
updating,
|
|
12
|
+
onStatusChange,
|
|
13
|
+
isSelected,
|
|
14
|
+
onSelect,
|
|
15
|
+
kanbanStatuses,
|
|
16
|
+
detailHref,
|
|
17
|
+
onUpdateDueDate,
|
|
18
|
+
onUpdateAssignee,
|
|
19
|
+
}: {
|
|
20
|
+
task: Task;
|
|
21
|
+
status: string;
|
|
22
|
+
overdue: boolean;
|
|
23
|
+
updating: boolean;
|
|
24
|
+
onStatusChange: (taskId: string, newStatus: string) => void;
|
|
25
|
+
isSelected: boolean;
|
|
26
|
+
onSelect: () => void;
|
|
27
|
+
kanbanStatuses: { key: string; label: string; color?: string }[];
|
|
28
|
+
detailHref: string;
|
|
29
|
+
onUpdateDueDate?: (taskId: string, dueDate: string | null) => void;
|
|
30
|
+
onUpdateAssignee?: (taskId: string, assigneeType: "HUMAN" | "AGENT", assigneeId?: string) => void;
|
|
31
|
+
}) {
|
|
32
|
+
// Format relative time (e.g., "2h ago", "3d ago")
|
|
33
|
+
function formatRelativeTime(date: string | Date | undefined): string {
|
|
34
|
+
if (!date) return "-";
|
|
35
|
+
const then = new Date(date);
|
|
36
|
+
const now = new Date();
|
|
37
|
+
const diffMs = now.getTime() - then.getTime();
|
|
38
|
+
const diffMins = Math.floor(diffMs / 60000);
|
|
39
|
+
const diffHours = Math.floor(diffMs / 3600000);
|
|
40
|
+
const diffDays = Math.floor(diffMs / 86400000);
|
|
41
|
+
|
|
42
|
+
if (diffMins < 60) return `${diffMins}m ago`;
|
|
43
|
+
if (diffHours < 24) return `${diffHours}h ago`;
|
|
44
|
+
if (diffDays < 7) return `${diffDays}d ago`;
|
|
45
|
+
return then.toLocaleDateString();
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// Get priority badge color
|
|
49
|
+
function getPriorityColor(priority?: string | null): string {
|
|
50
|
+
switch (priority?.toLowerCase()) {
|
|
51
|
+
case "critical": return "text-red-500 font-semibold";
|
|
52
|
+
case "high": return "text-red-400";
|
|
53
|
+
case "medium": return "text-yellow-400";
|
|
54
|
+
case "low": return "text-green-400";
|
|
55
|
+
default: return "text-zinc-500";
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
// Get assignee label
|
|
60
|
+
// Inline edit states
|
|
61
|
+
const [editingDueDate, setEditingDueDate] = useState(false);
|
|
62
|
+
const [editingAssignee, setEditingAssignee] = useState(false);
|
|
63
|
+
const [tempDueDate, setTempDueDate] = useState<string>("");
|
|
64
|
+
const [tempAssignee, setTempAssignee] = useState<string>("");
|
|
65
|
+
const [saving, setSaving] = useState(false);
|
|
66
|
+
|
|
67
|
+
const assigneeLabel =
|
|
68
|
+
task.assigneeType === "AGENT"
|
|
69
|
+
? task.claimedByAlias
|
|
70
|
+
? `@agent:${task.claimedByAlias}`
|
|
71
|
+
: "@agent"
|
|
72
|
+
: task.assignee?.name
|
|
73
|
+
? `@${task.assignee.name}`
|
|
74
|
+
: task.assignee?.email
|
|
75
|
+
? `@${task.assignee.email.split("@")[0]}`
|
|
76
|
+
: "-";
|
|
77
|
+
|
|
78
|
+
const rowClassName = isSelected
|
|
79
|
+
? `ring-1 ring-inset ring-blue-900/30 ${overdue ? "bg-red-950/10" : "bg-blue-950/20"}`
|
|
80
|
+
: overdue
|
|
81
|
+
? "bg-red-950/10 hover:bg-red-950/20"
|
|
82
|
+
: "hover:bg-zinc-800/30";
|
|
83
|
+
|
|
84
|
+
return (
|
|
85
|
+
<tr className={rowClassName}>
|
|
86
|
+
{/* Checkbox */}
|
|
87
|
+
<td className="px-3 py-2">
|
|
88
|
+
<input
|
|
89
|
+
type="checkbox"
|
|
90
|
+
checked={isSelected}
|
|
91
|
+
onChange={onSelect}
|
|
92
|
+
className="rounded border-zinc-600 bg-zinc-800 text-blue-600"
|
|
93
|
+
/>
|
|
94
|
+
</td>
|
|
95
|
+
|
|
96
|
+
{/* Title */}
|
|
97
|
+
<td className="px-4 py-2">
|
|
98
|
+
<div className="flex items-center gap-2">
|
|
99
|
+
<Link
|
|
100
|
+
href={detailHref}
|
|
101
|
+
className="block truncate text-left text-sm text-zinc-200 hover:text-zinc-100"
|
|
102
|
+
title={task.title}
|
|
103
|
+
>
|
|
104
|
+
{task.title}
|
|
105
|
+
</Link>
|
|
106
|
+
{overdue && <span className="text-xs text-red-400">overdue</span>}
|
|
107
|
+
</div>
|
|
108
|
+
</td>
|
|
109
|
+
|
|
110
|
+
{/* Status dropdown */}
|
|
111
|
+
<td className="px-4 py-2">
|
|
112
|
+
<select
|
|
113
|
+
value={status}
|
|
114
|
+
disabled={updating}
|
|
115
|
+
onChange={(e) => onStatusChange(task.id, e.target.value)}
|
|
116
|
+
className={`text-xs rounded border px-2 py-1 bg-zinc-900 border-zinc-700 text-zinc-300 ${
|
|
117
|
+
updating ? "opacity-50" : ""
|
|
118
|
+
}`}
|
|
119
|
+
>
|
|
120
|
+
{kanbanStatuses.map((s) => (
|
|
121
|
+
<option key={s.key} value={s.key}>
|
|
122
|
+
{s.label}
|
|
123
|
+
</option>
|
|
124
|
+
))}
|
|
125
|
+
</select>
|
|
126
|
+
</td>
|
|
127
|
+
|
|
128
|
+
{/* Due date - inline editable */}
|
|
129
|
+
<td className="px-4 py-2">
|
|
130
|
+
{editingDueDate ? (
|
|
131
|
+
<div className="flex items-center gap-1">
|
|
132
|
+
<input
|
|
133
|
+
type="date"
|
|
134
|
+
value={tempDueDate}
|
|
135
|
+
onChange={(e) => setTempDueDate(e.target.value)}
|
|
136
|
+
className="text-xs bg-zinc-800 border border-zinc-700 rounded px-1 py-0.5 text-zinc-300 w-28"
|
|
137
|
+
autoFocus
|
|
138
|
+
/>
|
|
139
|
+
<button
|
|
140
|
+
onClick={async () => {
|
|
141
|
+
setSaving(true);
|
|
142
|
+
await onUpdateDueDate?.(task.id, tempDueDate || null);
|
|
143
|
+
setSaving(false);
|
|
144
|
+
setEditingDueDate(false);
|
|
145
|
+
}}
|
|
146
|
+
disabled={saving}
|
|
147
|
+
className="text-xs text-green-500 hover:text-green-400"
|
|
148
|
+
>
|
|
149
|
+
✓
|
|
150
|
+
</button>
|
|
151
|
+
<button
|
|
152
|
+
onClick={() => setEditingDueDate(false)}
|
|
153
|
+
className="text-xs text-red-500 hover:text-red-400"
|
|
154
|
+
>
|
|
155
|
+
✕
|
|
156
|
+
</button>
|
|
157
|
+
</div>
|
|
158
|
+
) : (
|
|
159
|
+
<button
|
|
160
|
+
onClick={() => {
|
|
161
|
+
setTempDueDate(task.dueDate ? new Date(task.dueDate).toISOString().split("T")[0] : "");
|
|
162
|
+
setEditingDueDate(true);
|
|
163
|
+
}}
|
|
164
|
+
className={`text-xs ${overdue ? "text-red-400 font-medium" : "text-zinc-400"} hover:text-zinc-200 transition-colors text-left`}
|
|
165
|
+
>
|
|
166
|
+
{task.dueDate
|
|
167
|
+
? new Date(task.dueDate).toLocaleDateString(undefined, { month: "short", day: "numeric" })
|
|
168
|
+
: "-"}
|
|
169
|
+
</button>
|
|
170
|
+
)}
|
|
171
|
+
</td>
|
|
172
|
+
|
|
173
|
+
{/* Priority */}
|
|
174
|
+
<td className="px-4 py-2">
|
|
175
|
+
<span className={`text-xs ${getPriorityColor(task.priority)}`}>
|
|
176
|
+
{task.priority || "-"}
|
|
177
|
+
</span>
|
|
178
|
+
</td>
|
|
179
|
+
|
|
180
|
+
{/* Assignee - inline editable */}
|
|
181
|
+
<td className="px-4 py-2">
|
|
182
|
+
{editingAssignee ? (
|
|
183
|
+
<div className="flex items-center gap-1">
|
|
184
|
+
<select
|
|
185
|
+
value={tempAssignee}
|
|
186
|
+
onChange={(e) => setTempAssignee(e.target.value)}
|
|
187
|
+
className="text-xs bg-zinc-800 border border-zinc-700 rounded px-1 py-0.5 text-zinc-300"
|
|
188
|
+
autoFocus
|
|
189
|
+
>
|
|
190
|
+
<option value="">Unassigned</option>
|
|
191
|
+
<option value="AGENT">@agent</option>
|
|
192
|
+
<option value="HUMAN">Human</option>
|
|
193
|
+
</select>
|
|
194
|
+
<button
|
|
195
|
+
onClick={async () => {
|
|
196
|
+
setSaving(true);
|
|
197
|
+
// "" = unassigned (HUMAN with no assignee), "AGENT" = agent task,
|
|
198
|
+
// "HUMAN:<id>" = assigned to specific human
|
|
199
|
+
const assigneeType = tempAssignee === "" ? "HUMAN" : tempAssignee.split(":")[0] as "HUMAN" | "AGENT";
|
|
200
|
+
const assigneeId = tempAssignee.includes(":") ? tempAssignee.split(":")[1] : null;
|
|
201
|
+
await onUpdateAssignee?.(task.id, assigneeType, assigneeId ?? undefined);
|
|
202
|
+
setSaving(false);
|
|
203
|
+
setEditingAssignee(false);
|
|
204
|
+
}}
|
|
205
|
+
disabled={saving}
|
|
206
|
+
className="text-xs text-green-500 hover:text-green-400"
|
|
207
|
+
>
|
|
208
|
+
✓
|
|
209
|
+
</button>
|
|
210
|
+
<button
|
|
211
|
+
onClick={() => setEditingAssignee(false)}
|
|
212
|
+
className="text-xs text-red-500 hover:text-red-400"
|
|
213
|
+
>
|
|
214
|
+
✕
|
|
215
|
+
</button>
|
|
216
|
+
</div>
|
|
217
|
+
) : (
|
|
218
|
+
<button
|
|
219
|
+
onClick={() => {
|
|
220
|
+
const currentAssignee = task.assigneeType === "AGENT"
|
|
221
|
+
? task.claimedByAlias
|
|
222
|
+
? `AGENT:${task.claimedBy}`
|
|
223
|
+
: "AGENT"
|
|
224
|
+
: task.assignee?.id
|
|
225
|
+
? `HUMAN:${task.assignee.id}`
|
|
226
|
+
: "";
|
|
227
|
+
setTempAssignee(currentAssignee);
|
|
228
|
+
setEditingAssignee(true);
|
|
229
|
+
}}
|
|
230
|
+
className={`text-xs ${task.assigneeType === "AGENT" ? "text-blue-400" : "text-zinc-400"} hover:text-zinc-200 transition-colors text-left`}
|
|
231
|
+
>
|
|
232
|
+
{assigneeLabel}
|
|
233
|
+
</button>
|
|
234
|
+
)}
|
|
235
|
+
</td>
|
|
236
|
+
|
|
237
|
+
{/* Note/Folder */}
|
|
238
|
+
<td className="px-4 py-2">
|
|
239
|
+
<Link
|
|
240
|
+
href={`/notes/${task.note.id}`}
|
|
241
|
+
className="block text-xs text-zinc-500 hover:text-zinc-300 truncate max-w-[150px]"
|
|
242
|
+
title={task.note.title}
|
|
243
|
+
>
|
|
244
|
+
{task.note.folder && (
|
|
245
|
+
<span className="text-zinc-600">{task.note.folder.name} / </span>
|
|
246
|
+
)}
|
|
247
|
+
{task.note.title}
|
|
248
|
+
</Link>
|
|
249
|
+
</td>
|
|
250
|
+
|
|
251
|
+
{/* Updated */}
|
|
252
|
+
<td className="px-4 py-2">
|
|
253
|
+
<span className="text-xs text-zinc-500">
|
|
254
|
+
{formatRelativeTime(task.updatedAt)}
|
|
255
|
+
</span>
|
|
256
|
+
</td>
|
|
257
|
+
</tr>
|
|
258
|
+
);
|
|
259
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import { describe, it, expect } from "vitest";
|
|
3
|
+
import { render, screen, waitFor } from "@testing-library/react";
|
|
4
|
+
import userEvent from "@testing-library/user-event";
|
|
5
|
+
import { ToastProvider, useToast } from "./toast";
|
|
6
|
+
|
|
7
|
+
function Trigger() {
|
|
8
|
+
const toast = useToast();
|
|
9
|
+
return <button onClick={() => toast("Saved!", "success")}>fire</button>;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
describe("ToastProvider", () => {
|
|
13
|
+
it("shows a toast when triggered, then dismisses it", async () => {
|
|
14
|
+
const user = userEvent.setup();
|
|
15
|
+
render(
|
|
16
|
+
<ToastProvider>
|
|
17
|
+
<Trigger />
|
|
18
|
+
</ToastProvider>
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
// Nothing shown initially.
|
|
22
|
+
expect(screen.queryByText("Saved!")).toBeNull();
|
|
23
|
+
|
|
24
|
+
// Firing a toast renders its message.
|
|
25
|
+
await user.click(screen.getByText("fire"));
|
|
26
|
+
expect(await screen.findByText("Saved!")).toBeInTheDocument();
|
|
27
|
+
|
|
28
|
+
// Clicking dismiss removes it.
|
|
29
|
+
await user.click(screen.getByLabelText("Dismiss"));
|
|
30
|
+
await waitFor(() => expect(screen.queryByText("Saved!")).toBeNull());
|
|
31
|
+
});
|
|
32
|
+
|
|
33
|
+
it("is a no-op outside a provider (default context)", () => {
|
|
34
|
+
// useToast falls back to a noop so consumers never crash if unwrapped.
|
|
35
|
+
function Lone() {
|
|
36
|
+
const toast = useToast();
|
|
37
|
+
return <button onClick={() => toast("x")}>ok</button>;
|
|
38
|
+
}
|
|
39
|
+
render(<Lone />);
|
|
40
|
+
expect(screen.getByText("ok")).toBeInTheDocument();
|
|
41
|
+
});
|
|
42
|
+
});
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
createContext,
|
|
5
|
+
useCallback,
|
|
6
|
+
useContext,
|
|
7
|
+
useState,
|
|
8
|
+
} from "react";
|
|
9
|
+
import { Check, AlertTriangle, Info, X } from "lucide-react";
|
|
10
|
+
|
|
11
|
+
type Variant = "success" | "error" | "info";
|
|
12
|
+
type Toast = { id: number; message: string; variant: Variant };
|
|
13
|
+
|
|
14
|
+
const ToastCtx = createContext<(message: string, variant?: Variant) => void>(
|
|
15
|
+
() => {}
|
|
16
|
+
);
|
|
17
|
+
|
|
18
|
+
export function useToast() {
|
|
19
|
+
return useContext(ToastCtx);
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
const VARIANT: Record<Variant, { ring: string; icon: React.ReactNode }> = {
|
|
23
|
+
success: { ring: "border-emerald-700/60", icon: <Check size={15} className="text-emerald-400" /> },
|
|
24
|
+
error: { ring: "border-red-700/60", icon: <AlertTriangle size={15} className="text-red-400" /> },
|
|
25
|
+
info: { ring: "border-zinc-700", icon: <Info size={15} className="text-zinc-400" /> },
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
export function ToastProvider({ children }: { children: React.ReactNode }) {
|
|
29
|
+
const [toasts, setToasts] = useState<Toast[]>([]);
|
|
30
|
+
|
|
31
|
+
const toast = useCallback((message: string, variant: Variant = "info") => {
|
|
32
|
+
const id = Date.now() + Math.random();
|
|
33
|
+
setToasts((t) => [...t, { id, message, variant }]);
|
|
34
|
+
setTimeout(() => {
|
|
35
|
+
setToasts((t) => t.filter((x) => x.id !== id));
|
|
36
|
+
}, 4000);
|
|
37
|
+
}, []);
|
|
38
|
+
|
|
39
|
+
const dismiss = (id: number) => setToasts((t) => t.filter((x) => x.id !== id));
|
|
40
|
+
|
|
41
|
+
return (
|
|
42
|
+
<ToastCtx.Provider value={toast}>
|
|
43
|
+
{children}
|
|
44
|
+
{/* Sits above the mobile bottom-nav (bottom-20) and bottom-right on desktop. */}
|
|
45
|
+
<div
|
|
46
|
+
className="pointer-events-none fixed inset-x-0 bottom-20 z-[200] flex flex-col items-center gap-2 px-4 md:inset-x-auto md:right-4 md:bottom-4 md:items-end"
|
|
47
|
+
role="region"
|
|
48
|
+
aria-live="polite"
|
|
49
|
+
aria-label="Notifications"
|
|
50
|
+
>
|
|
51
|
+
{toasts.map((t) => (
|
|
52
|
+
<div
|
|
53
|
+
key={t.id}
|
|
54
|
+
className={`pointer-events-auto flex w-full max-w-sm items-center gap-2.5 rounded-lg border bg-zinc-900 px-3 py-2.5 shadow-xl ${VARIANT[t.variant].ring}`}
|
|
55
|
+
>
|
|
56
|
+
<span className="shrink-0">{VARIANT[t.variant].icon}</span>
|
|
57
|
+
<span className="flex-1 text-sm text-zinc-200">{t.message}</span>
|
|
58
|
+
<button
|
|
59
|
+
onClick={() => dismiss(t.id)}
|
|
60
|
+
aria-label="Dismiss"
|
|
61
|
+
className="shrink-0 text-zinc-600 transition-colors hover:text-zinc-300"
|
|
62
|
+
>
|
|
63
|
+
<X size={14} />
|
|
64
|
+
</button>
|
|
65
|
+
</div>
|
|
66
|
+
))}
|
|
67
|
+
</div>
|
|
68
|
+
</ToastCtx.Provider>
|
|
69
|
+
);
|
|
70
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Next.js instrumentation hook — runs once when the server starts,
|
|
3
|
+
* before any request handlers.
|
|
4
|
+
*
|
|
5
|
+
* Local mode (IS_CLOUD=false):
|
|
6
|
+
* 1. Initialise PGlite (async dynamic import + file open)
|
|
7
|
+
* 2. Apply any pending SQL migrations to the local DB
|
|
8
|
+
*
|
|
9
|
+
* Cloud mode (IS_CLOUD=true):
|
|
10
|
+
* No-op. Prisma clients are synchronously initialised at module load,
|
|
11
|
+
* and migrations are applied to Neon by the deploy pipeline.
|
|
12
|
+
*/
|
|
13
|
+
export async function register() {
|
|
14
|
+
if (process.env.NEXT_RUNTIME !== "nodejs") return;
|
|
15
|
+
if (process.env.IS_CLOUD === "true") return;
|
|
16
|
+
|
|
17
|
+
const { initLocalPrisma } = await import("./lib/prisma");
|
|
18
|
+
await initLocalPrisma();
|
|
19
|
+
|
|
20
|
+
const g = global as never as Record<string, unknown>;
|
|
21
|
+
const { runLocalMigrations } = await import("./lib/db-init");
|
|
22
|
+
await runLocalMigrations(g.briefPglite as Parameters<typeof runLocalMigrations>[0]);
|
|
23
|
+
}
|
package/lib/api-error.ts
ADDED
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { NextResponse } from "next/server";
|
|
2
|
+
import type { z } from "zod";
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Standard JSON error shape: `{ error, code? }`. Use across all route handlers
|
|
6
|
+
* so clients can rely on a single contract.
|
|
7
|
+
*/
|
|
8
|
+
export function apiError(message: string, status = 400, code?: string) {
|
|
9
|
+
return NextResponse.json(
|
|
10
|
+
code ? { error: message, code } : { error: message },
|
|
11
|
+
{ status }
|
|
12
|
+
);
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export const unauthorized = () => apiError("Unauthorized", 401);
|
|
16
|
+
export const noWorkspace = () => apiError("No workspace", 404);
|
|
17
|
+
export const notFound = (message = "Not found") => apiError(message, 404);
|
|
18
|
+
|
|
19
|
+
type ParseOk<T> = { data: T; response?: undefined };
|
|
20
|
+
type ParseErr = { data?: undefined; response: NextResponse };
|
|
21
|
+
|
|
22
|
+
/**
|
|
23
|
+
* Parse + validate a JSON request body against a zod schema.
|
|
24
|
+
* On failure returns `{ response }` (a ready-to-return 400) so handlers can do:
|
|
25
|
+
*
|
|
26
|
+
* const parsed = await parseJson(req, schema);
|
|
27
|
+
* if (parsed.response) return parsed.response;
|
|
28
|
+
* const { ... } = parsed.data;
|
|
29
|
+
*/
|
|
30
|
+
export async function parseJson<T extends z.ZodTypeAny>(
|
|
31
|
+
req: Request,
|
|
32
|
+
schema: T
|
|
33
|
+
): Promise<ParseOk<z.infer<T>> | ParseErr> {
|
|
34
|
+
let raw: unknown;
|
|
35
|
+
try {
|
|
36
|
+
raw = await req.json();
|
|
37
|
+
} catch {
|
|
38
|
+
return { response: apiError("Invalid JSON body", 400, "INVALID_JSON") };
|
|
39
|
+
}
|
|
40
|
+
const result = schema.safeParse(raw);
|
|
41
|
+
if (!result.success) {
|
|
42
|
+
const first = result.error.issues[0];
|
|
43
|
+
const path = first?.path.join(".");
|
|
44
|
+
const message = first
|
|
45
|
+
? `${path ? path + ": " : ""}${first.message}`
|
|
46
|
+
: "Invalid request";
|
|
47
|
+
return { response: apiError(message, 400, "VALIDATION") };
|
|
48
|
+
}
|
|
49
|
+
return { data: result.data };
|
|
50
|
+
}
|
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { shouldRunScheduledBackup } from "@/lib/backup/backup-runner";
|
|
3
|
+
|
|
4
|
+
describe("shouldRunScheduledBackup", () => {
|
|
5
|
+
it("runs only when the schedule is enabled and due", () => {
|
|
6
|
+
expect(
|
|
7
|
+
shouldRunScheduledBackup({
|
|
8
|
+
isElectron: true,
|
|
9
|
+
isCloudWorkspace: false,
|
|
10
|
+
scheduleEnabled: true,
|
|
11
|
+
destinationPath: "C:/Backups",
|
|
12
|
+
cadence: "DAILY",
|
|
13
|
+
lastBackupAt: new Date("2026-06-10T08:00:00.000Z"),
|
|
14
|
+
now: new Date("2026-06-11T08:01:00.000Z"),
|
|
15
|
+
})
|
|
16
|
+
).toBe(true);
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
it("skips cloud workspaces", () => {
|
|
20
|
+
expect(
|
|
21
|
+
shouldRunScheduledBackup({
|
|
22
|
+
isElectron: true,
|
|
23
|
+
isCloudWorkspace: true,
|
|
24
|
+
scheduleEnabled: true,
|
|
25
|
+
destinationPath: "C:/Backups",
|
|
26
|
+
cadence: "DAILY",
|
|
27
|
+
lastBackupAt: null,
|
|
28
|
+
now: new Date("2026-06-13T00:00:00.000Z"),
|
|
29
|
+
})
|
|
30
|
+
).toBe(false);
|
|
31
|
+
});
|
|
32
|
+
});
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import { isBackupDue } from "@/lib/backup/backup-schedule";
|
|
2
|
+
import type { BackupCadence } from "@/lib/backup/types";
|
|
3
|
+
|
|
4
|
+
export function shouldRunScheduledBackup(input: {
|
|
5
|
+
isElectron: boolean;
|
|
6
|
+
isCloudWorkspace: boolean;
|
|
7
|
+
scheduleEnabled: boolean;
|
|
8
|
+
destinationPath: string | null;
|
|
9
|
+
cadence: BackupCadence;
|
|
10
|
+
lastBackupAt: Date | null;
|
|
11
|
+
now?: Date;
|
|
12
|
+
}) {
|
|
13
|
+
if (!input.isElectron) return false;
|
|
14
|
+
if (input.isCloudWorkspace) return false;
|
|
15
|
+
if (!input.scheduleEnabled) return false;
|
|
16
|
+
if (!input.destinationPath) return false;
|
|
17
|
+
|
|
18
|
+
return isBackupDue(input.cadence, input.lastBackupAt, input.now ?? new Date());
|
|
19
|
+
}
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
import { describe, expect, it } from "vitest";
|
|
2
|
+
import { getNextBackupDueAt, isBackupDue } from "@/lib/backup/backup-schedule";
|
|
3
|
+
|
|
4
|
+
describe("backup schedule cadence", () => {
|
|
5
|
+
it("marks a daily backup due after 24 hours", () => {
|
|
6
|
+
const lastBackupAt = new Date("2026-06-10T08:00:00.000Z");
|
|
7
|
+
const now = new Date("2026-06-11T08:01:00.000Z");
|
|
8
|
+
|
|
9
|
+
expect(isBackupDue("DAILY", lastBackupAt, now)).toBe(true);
|
|
10
|
+
});
|
|
11
|
+
|
|
12
|
+
it("returns the next monthly due date from the last success time", () => {
|
|
13
|
+
const lastBackupAt = new Date("2026-01-31T09:00:00.000Z");
|
|
14
|
+
|
|
15
|
+
expect(getNextBackupDueAt("MONTHLY", lastBackupAt)?.toISOString()).toBe(
|
|
16
|
+
"2026-02-28T09:00:00.000Z"
|
|
17
|
+
);
|
|
18
|
+
});
|
|
19
|
+
|
|
20
|
+
it("treats a missing last backup as immediately due", () => {
|
|
21
|
+
expect(isBackupDue("WEEKLY", null, new Date("2026-06-13T00:00:00.000Z"))).toBe(true);
|
|
22
|
+
});
|
|
23
|
+
});
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import type { BackupCadence } from "@/lib/backup/types";
|
|
2
|
+
|
|
3
|
+
function getDaysInMonth(year: number, monthIndex: number) {
|
|
4
|
+
return new Date(Date.UTC(year, monthIndex + 1, 0)).getUTCDate();
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
function addMonthsClamped(date: Date, monthsToAdd: number) {
|
|
8
|
+
const year = date.getUTCFullYear();
|
|
9
|
+
const monthIndex = date.getUTCMonth();
|
|
10
|
+
const day = date.getUTCDate();
|
|
11
|
+
const nextMonthIndex = monthIndex + monthsToAdd;
|
|
12
|
+
const targetYear = year + Math.floor(nextMonthIndex / 12);
|
|
13
|
+
const normalizedMonthIndex = ((nextMonthIndex % 12) + 12) % 12;
|
|
14
|
+
const targetDay = Math.min(day, getDaysInMonth(targetYear, normalizedMonthIndex));
|
|
15
|
+
|
|
16
|
+
return new Date(
|
|
17
|
+
Date.UTC(
|
|
18
|
+
targetYear,
|
|
19
|
+
normalizedMonthIndex,
|
|
20
|
+
targetDay,
|
|
21
|
+
date.getUTCHours(),
|
|
22
|
+
date.getUTCMinutes(),
|
|
23
|
+
date.getUTCSeconds(),
|
|
24
|
+
date.getUTCMilliseconds()
|
|
25
|
+
)
|
|
26
|
+
);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export function getNextBackupDueAt(
|
|
30
|
+
cadence: BackupCadence,
|
|
31
|
+
lastBackupAt: Date | null
|
|
32
|
+
): Date | null {
|
|
33
|
+
if (!lastBackupAt) return null;
|
|
34
|
+
|
|
35
|
+
if (cadence === "DAILY") {
|
|
36
|
+
return new Date(lastBackupAt.getTime() + 24 * 60 * 60 * 1000);
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
if (cadence === "WEEKLY") {
|
|
40
|
+
return new Date(lastBackupAt.getTime() + 7 * 24 * 60 * 60 * 1000);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
return addMonthsClamped(lastBackupAt, 1);
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function isBackupDue(
|
|
47
|
+
cadence: BackupCadence,
|
|
48
|
+
lastBackupAt: Date | null,
|
|
49
|
+
now = new Date()
|
|
50
|
+
): boolean {
|
|
51
|
+
if (!lastBackupAt) return true;
|
|
52
|
+
|
|
53
|
+
const nextDueAt = getNextBackupDueAt(cadence, lastBackupAt);
|
|
54
|
+
return nextDueAt !== null && now >= nextDueAt;
|
|
55
|
+
}
|