pre-migration prepped
CI / build (push) Has been skipped
CI / deploy (push) Successful in 1m30s

This commit is contained in:
2026-03-19 19:27:29 -05:00
parent 2e2b31262b
commit ef6c43f0c2
4 changed files with 315 additions and 57 deletions
+2
View File
@@ -0,0 +1,2 @@
VITE_TASGRID_ENABLE_PROD_MIGRATION=true
VITE_TASGRID_MIGRATION_ADMIN_EMAILS=tcardoza@cardoza.construction
+7
View File
@@ -2,10 +2,17 @@ const rawMode = (import.meta.env.VITE_TASGRID_DATA_MODE || "prod").toLowerCase()
export const TASGRID_DATA_MODE = rawMode === "dev" ? "dev" : "prod"; export const TASGRID_DATA_MODE = rawMode === "dev" ? "dev" : "prod";
export const TASGRID_IS_DEV_DATA = TASGRID_DATA_MODE === "dev"; export const TASGRID_IS_DEV_DATA = TASGRID_DATA_MODE === "dev";
const rawMigrationAdmins = import.meta.env.VITE_TASGRID_MIGRATION_ADMIN_EMAILS || "";
const rawMigrationEnabled = (import.meta.env.VITE_TASGRID_ENABLE_PROD_MIGRATION || "").toLowerCase();
export const POCKETBASE_URL = import.meta.env.VITE_POCKETBASE_URL || "https://pocketbase.ccllc.pro"; export const POCKETBASE_URL = import.meta.env.VITE_POCKETBASE_URL || "https://pocketbase.ccllc.pro";
export const AUTH_STORE_KEY = TASGRID_IS_DEV_DATA ? "tasgrid_dev_auth" : "tasgrid_auth"; export const AUTH_STORE_KEY = TASGRID_IS_DEV_DATA ? "tasgrid_dev_auth" : "tasgrid_auth";
export const STORAGE_KEY_PREFIX = TASGRID_IS_DEV_DATA ? "tasgrid_dev" : "tasgrid"; export const STORAGE_KEY_PREFIX = TASGRID_IS_DEV_DATA ? "tasgrid_dev" : "tasgrid";
export const PWA_CACHE_SUFFIX = TASGRID_IS_DEV_DATA ? "dev" : "prod"; export const PWA_CACHE_SUFFIX = TASGRID_IS_DEV_DATA ? "dev" : "prod";
export const PROD_MIGRATION_ENABLED = rawMigrationEnabled === "true";
export const PROD_MIGRATION_ADMIN_EMAILS = rawMigrationAdmins
.split(",")
.map((email: string) => email.trim().toLowerCase())
.filter(Boolean);
export const withDataSuffix = (base: string) => TASGRID_IS_DEV_DATA ? `${base}_Dev` : base; export const withDataSuffix = (base: string) => TASGRID_IS_DEV_DATA ? `${base}_Dev` : base;
+202 -54
View File
@@ -3,7 +3,7 @@ import { createSignal, createEffect, createRoot } from "solid-js";
import { pb } from "@/lib/pocketbase"; import { pb } from "@/lib/pocketbase";
import { toast } from "solid-sonner"; import { toast } from "solid-sonner";
import { CONTEXTS_COLLECTION, NOTES_COLLECTION, TASGRID_COLLECTION, URGENCY_HOURS, SIZE_HOURS } from "@/lib/constants"; import { CONTEXTS_COLLECTION, NOTES_COLLECTION, TASGRID_COLLECTION, URGENCY_HOURS, SIZE_HOURS } from "@/lib/constants";
import { STORAGE_KEY_PREFIX, TASGRID_DATA_MODE } from "@/lib/app-config"; import { STORAGE_KEY_PREFIX, TASGRID_DATA_MODE, TASGRID_IS_DEV_DATA, PROD_MIGRATION_ADMIN_EMAILS, PROD_MIGRATION_ENABLED } from "@/lib/app-config";
import { buildNoteTag, buildShareTag, normalizeContextKey, normalizeNoteKey, parseTags } from "@/lib/tags"; import { buildNoteTag, buildShareTag, normalizeContextKey, normalizeNoteKey, parseTags } from "@/lib/tags";
// Helper to get the current user's personalized favorite tag // Helper to get the current user's personalized favorite tag
@@ -536,6 +536,26 @@ const buildTaskTags = (
return [...new Set(tags.filter(Boolean))]; return [...new Set(tags.filter(Boolean))];
}; };
const dedupeShareRefs = (refs: TaskShareRef[]) => {
const seen = new Set<string>();
return refs.filter(ref => {
const key = `${ref.kind}:${ref.contextId}:${ref.key}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
};
const dedupeNoteRefs = (refs: NoteRef[]) => {
const seen = new Set<string>();
return refs.filter(ref => {
const key = `${ref.noteId}:${ref.key}`;
if (seen.has(key)) return false;
seen.add(key);
return true;
});
};
const withActiveContextTags = (rawTags: string[]) => { const withActiveContextTags = (rawTags: string[]) => {
const nextTags = [...rawTags]; const nextTags = [...rawTags];
const ctx = currentTaskContext(); const ctx = currentTaskContext();
@@ -586,6 +606,17 @@ const setContexts = (contexts: ShareContext[]) => {
setStore("tagDefinitions", reconcile(mergedDefs)); setStore("tagDefinitions", reconcile(mergedDefs));
}; };
const loadContextsIntoStore = async () => {
const contextRecords = await pb.collection(CONTEXTS_COLLECTION).getFullList({
sort: 'displayName,key',
requestKey: null
}).catch(() => []);
const nextContexts = contextRecords.map(mapRecordToContext);
setContexts(nextContexts);
setStore("shareRules", nextContexts.map(mapContextToShareRule));
return nextContexts;
};
const getPersonalContext = (userId = pb.authStore.model?.id) => const getPersonalContext = (userId = pb.authStore.model?.id) =>
userId userId
? store.contexts.find(context => ? store.contexts.find(context =>
@@ -631,7 +662,11 @@ export const matchesFilter = (task: Task) => {
); );
const inPersonalContext = !!personalContext && task.shareRefs.some(ref => const inPersonalContext = !!personalContext && task.shareRefs.some(ref =>
ref.kind === "user" && ref.kind === "user" &&
(ref.contextId === personalContext.id || ref.key === personalContext.key) (
ref.contextId === personalContext.id ||
ref.key === personalContext.key ||
normalizeContextKey(personalContext.displayName) === ref.key
)
); );
// 0. Context Filter // 0. Context Filter
@@ -905,6 +940,66 @@ const mapRecordToTask = (r: any): Task => {
}; };
}; };
const buildNoteLinkMap = (notes: Note[]) => {
const links = new Map<string, NoteRef[]>();
for (const note of notes) {
for (const taskId of note.tasks || []) {
const existing = links.get(taskId) || [];
existing.push({ noteId: note.id, key: note.key });
links.set(taskId, existing);
}
}
return links;
};
const buildTaskMigrationPayload = (
task: Task,
ownerContextByUserId: Map<string, ShareContext>,
linkedNoteRefs: NoteRef[] = [],
fallbackUserId?: string
) => {
const favoriteTag = task.tags.find(tag => tag.endsWith("_favorite__"));
const ownerContext = task.ownerId ? ownerContextByUserId.get(task.ownerId) : undefined;
let nextShareRefs = dedupeShareRefs(task.shareRefs);
let nextNoteRefs = dedupeNoteRefs([...task.noteRefs, ...linkedNoteRefs]);
const hasHandoffBucket = nextShareRefs.some(ref => {
const context = getContextByRef(ref);
return context?.kind === "bucket" && context.policy === "handoff";
});
if (ownerContext) {
const matchesOwnerContext = (ref: TaskShareRef) =>
ref.kind === "user" && (
ref.contextId === ownerContext.id ||
ref.key === ownerContext.key ||
normalizeContextKey(ownerContext.displayName) === ref.key
);
if (hasHandoffBucket) {
nextShareRefs = nextShareRefs.filter(ref => !matchesOwnerContext(ref));
} else if (!nextShareRefs.some(matchesOwnerContext)) {
nextShareRefs = [
{
contextId: ownerContext.id,
key: ownerContext.key,
kind: "user" as const
},
...nextShareRefs
];
}
}
nextShareRefs = dedupeShareRefs(nextShareRefs);
return {
tags: buildTaskTags(task.labelTags, nextShareRefs, nextNoteRefs, favoriteTag),
labelTags: task.labelTags,
shareRefs: nextShareRefs,
noteRefs: nextNoteRefs,
createdBy: task.createdBy || task.ownerId || fallbackUserId || pb.authStore.model?.id || ""
};
};
export const uploadTaskAttachment = async (taskId: string, file: File): Promise<{ url: string, filename: string }> => { export const uploadTaskAttachment = async (taskId: string, file: File): Promise<{ url: string, filename: string }> => {
try { try {
const formData = new FormData(); const formData = new FormData();
@@ -1247,7 +1342,7 @@ export const subscribeToRealtime = async () => {
}); });
}; };
const runLegacyTagMigration = async () => { const runLegacyTagMigration = async (force = false) => {
const currentUserId = pb.authStore.model?.id; const currentUserId = pb.authStore.model?.id;
if (!currentUserId) return; if (!currentUserId) return;
@@ -1256,13 +1351,45 @@ const runLegacyTagMigration = async () => {
const rawPrefs = userRec.Taskgrid_pref || {}; const rawPrefs = userRec.Taskgrid_pref || {};
const prefs = readScopedPrefs(rawPrefs); const prefs = readScopedPrefs(rawPrefs);
if (prefs.migratedStructuredTagsV2) { if (!force && prefs.migratedStructuredTagsV2) {
return; // Already migrated return; // Already migrated
} }
console.log("[MIGRATION] Starting structured tag migration..."); console.log("[MIGRATION] Starting structured tag migration...");
await syncUserContexts(); await syncUserContexts();
const contexts = await loadContextsIntoStore();
const ownerContextByUserId = new Map(
contexts
.filter(context => context.kind === "user" && !!context.targetUserId && !context.deletedAt)
.map(context => [context.targetUserId as string, context])
);
const notes = await pb.collection(NOTES_COLLECTION).getFullList({
requestKey: null
});
const migratedNotes: Note[] = [];
for (const noteRecord of notes) {
const key = normalizeNoteKey(noteRecord.key || noteRecord.title || noteRecord.id);
const existingTags = Array.isArray(noteRecord.tags) ? noteRecord.tags.filter(Boolean) : [];
const nextTags = [...new Set([buildNoteTag(key), ...existingTags])];
const nextTasks = [...new Set((Array.isArray(noteRecord.tasks) ? noteRecord.tasks : []).filter(Boolean))];
await pb.collection(NOTES_COLLECTION).update(noteRecord.id, {
key,
tags: nextTags,
tasks: nextTasks
}, { requestKey: null });
migratedNotes.push({
...mapRecordToNote(noteRecord),
key,
tags: nextTags,
tasks: nextTasks
});
}
setStore("notes", migratedNotes);
const noteLinksByTaskId = buildNoteLinkMap(migratedNotes);
const tasks = await pb.collection(TASGRID_COLLECTION).getFullList({ const tasks = await pb.collection(TASGRID_COLLECTION).getFullList({
requestKey: null requestKey: null
@@ -1270,24 +1397,13 @@ const runLegacyTagMigration = async () => {
for (const taskRecord of tasks) { for (const taskRecord of tasks) {
if (taskRecord.tags?.includes("__template__")) continue; if (taskRecord.tags?.includes("__template__")) continue;
const task = mapRecordToTask(taskRecord); const task = mapRecordToTask(taskRecord);
await pb.collection(TASGRID_COLLECTION).update(task.id, { const payload = buildTaskMigrationPayload(
tags: task.tags, task,
labelTags: task.labelTags, ownerContextByUserId,
shareRefs: task.shareRefs, noteLinksByTaskId.get(task.id) || [],
noteRefs: task.noteRefs, currentUserId
createdBy: task.createdBy || task.ownerId || currentUserId );
}, { requestKey: null }); await pb.collection(TASGRID_COLLECTION).update(task.id, payload, { requestKey: null });
}
const notes = await pb.collection(NOTES_COLLECTION).getFullList({
requestKey: null
});
for (const noteRecord of notes) {
const note = mapRecordToNote(noteRecord);
await pb.collection(NOTES_COLLECTION).update(note.id, {
key: note.key,
tags: note.tags
}, { requestKey: null });
} }
const nextPrefs = { ...prefs, migratedStructuredTagsV2: true }; const nextPrefs = { ...prefs, migratedStructuredTagsV2: true };
@@ -1331,13 +1447,7 @@ const syncUserContexts = async () => {
} }
if (createdAny || store.contexts.length === 0) { if (createdAny || store.contexts.length === 0) {
const nextContextRecords = await pb.collection(CONTEXTS_COLLECTION).getFullList({ await loadContextsIntoStore();
sort: 'displayName,key',
requestKey: null
}).catch(() => []);
const nextContexts = nextContextRecords.map(mapRecordToContext);
setContexts(nextContexts);
setStore("shareRules", nextContexts.map(mapContextToShareRule));
} }
}; };
@@ -1382,7 +1492,7 @@ const syncSupervisedContexts = async () => {
} }
}; };
const runPersonalContextMigration = async () => { const runPersonalContextMigration = async (force = false) => {
const currentUserId = pb.authStore.model?.id; const currentUserId = pb.authStore.model?.id;
if (!currentUserId) return; if (!currentUserId) return;
@@ -1391,18 +1501,19 @@ const runPersonalContextMigration = async () => {
const rawPrefs = userRec.Taskgrid_pref || {}; const rawPrefs = userRec.Taskgrid_pref || {};
const prefs = readScopedPrefs(rawPrefs); const prefs = readScopedPrefs(rawPrefs);
if (prefs.migratedPersonalContextRefsV1) { if (!force && prefs.migratedPersonalContextRefsV1) {
return; return;
} }
const contexts = store.contexts.length > 0 const contexts = store.contexts.length > 0
? store.contexts ? store.contexts
: (await pb.collection(CONTEXTS_COLLECTION).getFullList({ requestKey: null })).map(mapRecordToContext); : await loadContextsIntoStore();
const ownerContextByUserId = new Map( const ownerContextByUserId = new Map(
contexts contexts
.filter(context => context.kind === "user" && !!context.targetUserId && !context.deletedAt) .filter(context => context.kind === "user" && !!context.targetUserId && !context.deletedAt)
.map(context => [context.targetUserId as string, context]) .map(context => [context.targetUserId as string, context])
); );
const noteLinksByTaskId = buildNoteLinkMap(store.notes);
const taskRecords = await pb.collection(TASGRID_COLLECTION).getFullList({ const taskRecords = await pb.collection(TASGRID_COLLECTION).getFullList({
fields: LIST_FIELDS, fields: LIST_FIELDS,
@@ -1414,30 +1525,19 @@ const runPersonalContextMigration = async () => {
const task = mapRecordToTask(taskRecord); const task = mapRecordToTask(taskRecord);
if (!task.ownerId) continue; if (!task.ownerId) continue;
const payload = buildTaskMigrationPayload(
const ownerContext = ownerContextByUserId.get(task.ownerId); task,
if (!ownerContext) continue; ownerContextByUserId,
noteLinksByTaskId.get(task.id) || [],
const alreadyHasOwnerContext = task.shareRefs.some(ref => currentUserId
ref.kind === "user" &&
(ref.contextId === ownerContext.id || ref.key === ownerContext.key)
); );
if (alreadyHasOwnerContext) continue;
const favoriteTag = task.tags.find(tag => tag.endsWith("_favorite__")); const sameShareRefs = JSON.stringify(payload.shareRefs) === JSON.stringify(task.shareRefs);
const nextShareRefs = [ const sameNoteRefs = JSON.stringify(payload.noteRefs) === JSON.stringify(task.noteRefs);
{ const sameTags = JSON.stringify(payload.tags) === JSON.stringify(task.tags);
contextId: ownerContext.id, if (sameShareRefs && sameNoteRefs && sameTags && payload.createdBy === task.createdBy) continue;
key: ownerContext.key,
kind: "user" as const
},
...task.shareRefs
];
await pb.collection(TASGRID_COLLECTION).update(task.id, { await pb.collection(TASGRID_COLLECTION).update(task.id, payload, { requestKey: null });
shareRefs: nextShareRefs,
tags: buildTaskTags(task.labelTags, nextShareRefs, task.noteRefs, favoriteTag)
}, { requestKey: null });
} }
await pb.collection('users').update(currentUserId, { await pb.collection('users').update(currentUserId, {
@@ -1451,6 +1551,52 @@ const runPersonalContextMigration = async () => {
} }
}; };
export const runDevDataMigration = async () => {
if (!pb.authStore.isValid) return;
if (!TASGRID_IS_DEV_DATA) {
toast.error("Dev migration is only available in dev data mode.");
return;
}
await toast.promise((async () => {
await runLegacyTagMigration(true);
await runPersonalContextMigration(true);
await loadContextsIntoStore();
await syncSupervisedContexts();
return "Dev data migration completed.";
})(), {
loading: "Running dev data migration...",
success: (message) => message,
error: "Dev data migration failed."
});
};
export const canRunProdMigration = () => {
if (TASGRID_IS_DEV_DATA || !PROD_MIGRATION_ENABLED) return false;
const email = (pb.authStore.model?.email || "").trim().toLowerCase();
return !!email && PROD_MIGRATION_ADMIN_EMAILS.includes(email);
};
export const runProdDataMigration = async () => {
if (!pb.authStore.isValid) return;
if (!canRunProdMigration()) {
toast.error("Prod migration is not enabled for this account.");
return;
}
await toast.promise((async () => {
await runLegacyTagMigration(true);
await runPersonalContextMigration(true);
await loadContextsIntoStore();
await syncSupervisedContexts();
return "Production data migration completed.";
})(), {
loading: "Running production data migration...",
success: (message) => message,
error: "Production data migration failed."
});
};
export const initStore = async () => { export const initStore = async () => {
if (!pb.authStore.isValid || store.isInitializing) return; if (!pb.authStore.isValid || store.isInitializing) return;
@@ -1744,9 +1890,11 @@ export const initStore = async () => {
// Fire off background sync // Fire off background sync
await backgroundSync(); await backgroundSync();
// Run one-time migration for legacy tags // Run one-time migration automatically only in dev data mode.
if (TASGRID_IS_DEV_DATA) {
await runLegacyTagMigration(); await runLegacyTagMigration();
await runPersonalContextMigration(); await runPersonalContextMigration();
}
// Separate Templates load (less critical) // Separate Templates load (less critical)
try { try {
+102 -1
View File
@@ -1,6 +1,6 @@
import { type Component, For, Show, createSignal, lazy, Suspense } from "solid-js"; import { type Component, For, Show, createSignal, lazy, Suspense } from "solid-js";
import { ThemeToggle } from "../components/ThemeToggle"; import { ThemeToggle } from "../components/ThemeToggle";
import { store, restoreTask, deleteTaskPermanently, restoreNote, deleteNotePermanently, addTemplate, removeTemplate, updateTemplate, upsertTagDefinition, removeTagDefinition, renameTagDefinition, addShareRule, removeShareRule, updateShareRule, createBucket, updateBucket, deleteBucket, toggleBucketSubscription, updateUserContextPolicy, savePersonalContextSupervisors } from "@/store"; import { store, restoreTask, deleteTaskPermanently, restoreNote, deleteNotePermanently, addTemplate, removeTemplate, updateTemplate, upsertTagDefinition, removeTagDefinition, renameTagDefinition, addShareRule, removeShareRule, updateShareRule, createBucket, updateBucket, deleteBucket, toggleBucketSubscription, updateUserContextPolicy, savePersonalContextSupervisors, runDevDataMigration, runProdDataMigration, canRunProdMigration } from "@/store";
import { useTheme } from "@/components/ThemeProvider"; import { useTheme } from "@/components/ThemeProvider";
import { Trash2, Undo2, ArrowLeftRight, Tag, ChevronDown, ChevronRight, Share2, Users, HelpCircle, Copy, Plus, Type, Upload, Box, Edit2, Archive } from "lucide-solid"; import { Trash2, Undo2, ArrowLeftRight, Tag, ChevronDown, ChevronRight, Share2, Users, HelpCircle, Copy, Plus, Type, Upload, Box, Edit2, Archive } from "lucide-solid";
import { TagPicker } from "@/components/TagPicker"; import { TagPicker } from "@/components/TagPicker";
@@ -10,6 +10,7 @@ import { toast } from "solid-sonner";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select"; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { PRIORITY_OPTIONS, URGENCY_OPTIONS } from "@/lib/constants"; import { PRIORITY_OPTIONS, URGENCY_OPTIONS } from "@/lib/constants";
import { pb } from "@/lib/pocketbase"; import { pb } from "@/lib/pocketbase";
import { TASGRID_IS_DEV_DATA } from "@/lib/app-config";
import { createEffect } from "solid-js"; import { createEffect } from "solid-js";
const TaskEditor = lazy(() => import("../components/TaskEditor").then(m => ({ default: m.TaskEditor }))); const TaskEditor = lazy(() => import("../components/TaskEditor").then(m => ({ default: m.TaskEditor })));
@@ -23,6 +24,10 @@ export const SettingsView: Component<{ setView?: (v: string) => void }> = (props
const [isSharingOpen, setIsSharingOpen] = createSignal(false); const [isSharingOpen, setIsSharingOpen] = createSignal(false);
const [isImportOpen, setIsImportOpen] = createSignal(false); const [isImportOpen, setIsImportOpen] = createSignal(false);
const [isBucketsOpen, setIsBucketsOpen] = createSignal(false); const [isBucketsOpen, setIsBucketsOpen] = createSignal(false);
const [isDevToolsOpen, setIsDevToolsOpen] = createSignal(false);
const [isProdMigrationOpen, setIsProdMigrationOpen] = createSignal(false);
const [isRunningDevMigration, setIsRunningDevMigration] = createSignal(false);
const [isRunningProdMigration, setIsRunningProdMigration] = createSignal(false);
const [expandedTemplateId, setExpandedTemplateId] = createSignal<string | null>(null); const [expandedTemplateId, setExpandedTemplateId] = createSignal<string | null>(null);
const [shareUserId, setShareUserId] = createSignal(""); const [shareUserId, setShareUserId] = createSignal("");
const [shareType, setShareType] = createSignal<'all' | 'tag'>('tag'); const [shareType, setShareType] = createSignal<'all' | 'tag'>('tag');
@@ -1053,6 +1058,102 @@ export const SettingsView: Component<{ setView?: (v: string) => void }> = (props
</div> </div>
</div> </div>
<Show when={TASGRID_IS_DEV_DATA}>
<div class="space-y-3">
<h2 class="text-xs font-bold text-muted-foreground/40 uppercase tracking-widest pl-1">Dev Tools</h2>
<section class="p-4 sm:p-5 rounded-2xl border border-amber-500/20 bg-amber-500/5 shadow-sm space-y-4 overflow-hidden">
<div
class="flex items-center justify-between cursor-pointer group"
onClick={() => setIsDevToolsOpen(!isDevToolsOpen())}
>
<div class="space-y-0.5">
<h3 class="text-base font-semibold flex items-center gap-2">
<Archive size={16} />
Dev Data Migration
</h3>
<p class="text-sm text-muted-foreground">
Rebuild canonical note keys, task `shareRefs` and `noteRefs`, personal user contexts, and bucket handoff cleanup for dev data.
</p>
</div>
<div class="w-8 h-8 rounded-full flex items-center justify-center bg-muted/50 group-hover:bg-muted transition-colors">
{isDevToolsOpen() ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</div>
</div>
<Show when={isDevToolsOpen()}>
<div class="pt-2 animate-in fade-in slide-in-from-top-2 duration-300 space-y-3">
<p class="text-[0.6875rem] text-muted-foreground">
This is a dev-only repair tool. It updates notes and tasks in the current `*_Dev` collections to the latest key and context model.
</p>
<Button
variant="outline"
class="w-full sm:w-auto"
disabled={isRunningDevMigration()}
onClick={async () => {
setIsRunningDevMigration(true);
try {
await runDevDataMigration();
} finally {
setIsRunningDevMigration(false);
}
}}
>
{isRunningDevMigration() ? "Running Migration..." : "Run Dev Data Migration"}
</Button>
</div>
</Show>
</section>
</div>
</Show>
<Show when={canRunProdMigration()}>
<div class="space-y-3">
<h2 class="text-xs font-bold text-muted-foreground/40 uppercase tracking-widest pl-1">Production Migration</h2>
<section class="p-4 sm:p-5 rounded-2xl border border-red-500/20 bg-red-500/5 shadow-sm space-y-4 overflow-hidden">
<div
class="flex items-center justify-between cursor-pointer group"
onClick={() => setIsProdMigrationOpen(!isProdMigrationOpen())}
>
<div class="space-y-0.5">
<h3 class="text-base font-semibold flex items-center gap-2">
<Archive size={16} />
Prod Data Migration
</h3>
<p class="text-sm text-muted-foreground">
Admin-only one-shot migration for production tasks, notes, contexts, and note-task links.
</p>
</div>
<div class="w-8 h-8 rounded-full flex items-center justify-center bg-muted/50 group-hover:bg-muted transition-colors">
{isProdMigrationOpen() ? <ChevronDown size={16} /> : <ChevronRight size={16} />}
</div>
</div>
<Show when={isProdMigrationOpen()}>
<div class="pt-2 animate-in fade-in slide-in-from-top-2 duration-300 space-y-3">
<p class="text-[0.6875rem] text-muted-foreground">
Run this only after the PocketBase production schema is ready and you have a fresh backup. This updates live production data in place.
</p>
<Button
variant="destructive"
class="w-full sm:w-auto"
disabled={isRunningProdMigration()}
onClick={async () => {
setIsRunningProdMigration(true);
try {
await runProdDataMigration();
} finally {
setIsRunningProdMigration(false);
}
}}
>
{isRunningProdMigration() ? "Running Production Migration..." : "Run Production Migration"}
</Button>
</div>
</Show>
</section>
</div>
</Show>
{/* Organization Category */} {/* Organization Category */}
<div class="space-y-3"> <div class="space-y-3">
<h2 class="text-xs font-bold text-muted-foreground/40 uppercase tracking-widest pl-1">Organization</h2> <h2 class="text-xs font-bold text-muted-foreground/40 uppercase tracking-widest pl-1">Organization</h2>