diff --git a/TODO b/TODO index 82ddad5..b46d7d1 100644 --- a/TODO +++ b/TODO @@ -1,8 +1,16 @@ Quick list +inline edit: +- fix back button behaviour +- generalise migrations + +WYSIWYG editor: +- template edited with WYSIWYG +- editing a template from a repeatable, do we need to change anything to make that look seamless now? + Revisit typing. We should have internal types not bound to PouchDB structures that mean the slug against the repeatable are cleanly typed with no ambiguiity. -Button to edit template on repeatable view. Will edit existing repeatables. This has downsides because repeatable data is just an array of booleans, not a map. Think about that but probably don't care for now. +Move to pnpm because I use it everywhere else. Spend a pomo looking into shorter and less ugly variations on raw uuids to clean up URLs etc. diff --git a/inline-edit.md b/inline-edit.md new file mode 100644 index 0000000..15f43c6 --- /dev/null +++ b/inline-edit.md @@ -0,0 +1,504 @@ +# Inline Template Edit Feature Plan + +This document outlines the plan for supporting editing a template from within the repeatable view, with automatic migration of checkbox state when the template changes. + +## Current State Analysis + +### Data Structures +- **TemplateDoc**: Contains `values: boolean[]` (checkbox defaults) and `markdown` (content with checkboxes) +- **RepeatableDoc**: Contains `values: boolean[]` (current checkbox state) and `template: DocId` (exact version reference) +- Checkboxes are indexed by document order via `rehypeCheckboxIndex` plugin +- Template versions follow pattern: `repeatable:template::` + +### Current Limitations +- No way to upgrade a repeatable to a newer template version +- No intelligent mapping when checkboxes are added/removed/reordered +- Values array uses positional indexing - changes to checkbox count break alignment + +--- + +## Migration Strategy: Unique Checkbox IDs + +Assign a unique ID to each checkbox when created. Store values as a `Record` instead of `boolean[]`. + +### Data Structure Changes + +```typescript +// Template +interface TemplateDoc { + // ... existing fields + values: CheckboxValue[]; // Changed from boolean[] +} + +interface CheckboxValue { + id: string; // UUID, stable across edits + default: boolean; // Default checked state for new repeatables +} + +// Repeatable +interface RepeatableDoc { + // ... existing fields + values: Record; // Map of checkbox ID -> checked state +} +``` + +### Markdown Storage + +Embed IDs in the markdown using HTML comments: +```markdown +- [ ] First task +- [ ] Second task +``` + +Comments are invisible when rendered but visible in the editor. + +### Why This Approach + +- **Deterministic migration** - IDs provide exact mapping between old and new template versions +- **Works with any edit** - add, remove, reorder checkboxes all work correctly +- **No fuzzy matching ambiguity** - identical checkbox labels don't cause problems +- **IDs survive copy/paste** within the editor + +### Migration Process + +1. On app startup, check for legacy format (array of booleans in template) +2. If found, show full-screen migration screen with progress bar (blocks app) +3. For each template: parse markdown, assign IDs, convert values array +4. For each repeatable: convert to Record using positional mapping to current template +5. Mark migration complete in localStorage + +--- + +## Feature Implementation Plan + +### Phase 1: Data Structure Migration + +#### 1.1 Update Types +**File**: `src/shared/types.ts` + +```typescript +export interface CheckboxValue { + id: string; + default: boolean; +} + +export interface TemplateDoc extends Doc { + // ... existing + values: CheckboxValue[]; // Breaking change + schemaVersion: 2; // New field to identify migrated docs +} + +export interface RepeatableDoc extends Doc { + // ... existing + values: Record; // Breaking change + schemaVersion: 2; +} + +// Legacy types for migration +export interface LegacyTemplateDoc extends Doc { + values: boolean[]; + schemaVersion?: undefined; +} + +export interface LegacyRepeatableDoc extends Doc { + values: boolean[]; + schemaVersion?: undefined; +} +``` + +#### 1.2 Migration Screen Component +**File**: `src/client/features/Migration/MigrationScreen.tsx` + +- Full-screen overlay that blocks app until migration completes +- Progress bar showing documents processed +- Error handling with retry capability + +#### 1.3 Migration Logic +**File**: `src/client/features/Migration/migrateDocs.ts` + +```typescript +export async function migrateDatabase(db: Database): Promise { + const allDocs = await db.allDocs({ include_docs: true }); + + const templates = allDocs.rows.filter(isLegacyTemplate); + const repeatables = allDocs.rows.filter(isLegacyRepeatable); + + // Migrate templates first (repeatables depend on them) + for (const template of templates) { + await migrateTemplate(db, template); + onProgress(current, total); + } + + // Then migrate repeatables + for (const repeatable of repeatables) { + await migrateRepeatable(db, repeatable); + onProgress(current, total); + } +} +``` + +#### 1.4 Checkbox ID Parsing/Serialization +**File**: `src/client/features/Repeatable/checkboxIds.ts` + +```typescript +// Parse markdown and extract checkbox IDs +// Returns array of { id, lineIndex } in document order +export function parseCheckboxIds(markdown: string): CheckboxInfo[]; + +// Insert IDs for any checkboxes missing them +export function ensureCheckboxIds(markdown: string): string; + +// When parsing for render, provide ID mapping +export function getCheckboxIdAtIndex(markdown: string, index: number): string; +``` + +### Phase 2: Core Migration Functions + +#### 2.1 Template Migration +**File**: `src/client/features/Migration/migrateTemplate.ts` + +```typescript +export function migrateTemplate(template: LegacyTemplateDoc): TemplateDoc { + // 1. Parse markdown to find checkboxes + // 2. Assign UUID to each checkbox + // 3. Insert IDs as HTML comments + // 4. Convert values array to CheckboxValue[] + // 5. Set schemaVersion: 2 +} +``` + +#### 2.2 Repeatable Migration +**File**: `src/client/features/Migration/migrateRepeatable.ts` + +```typescript +export function migrateRepeatable( + repeatable: LegacyRepeatableDoc, + template: TemplateDoc // Already migrated +): RepeatableDoc { + // 1. Get checkbox IDs from template (in document order) + // 2. Map positional values to ID-keyed record + // 3. Set schemaVersion: 2 +} +``` + +### Phase 3: Update Runtime Code + +#### 3.1 Checkbox Rendering +**File**: `src/client/features/Repeatable/RepeatableRenderer.tsx` + +- Update to work with `Record` values +- Pass checkbox ID to toggle handler instead of index + +#### 3.2 Checkbox Context +**File**: `src/client/features/Repeatable/CheckboxContext.tsx` + +```typescript +export type CheckboxContextType = { + values: Record; + onChange?: (checkboxId: string) => void; // Changed from idx + disabled: boolean; + registerButton: (checkboxId: string, element: HTMLElement | null) => void; +}; +``` + +#### 3.3 Rehype Plugin Update +**File**: `src/client/features/Repeatable/rehypeCheckboxIndex.ts` + +- Update to extract checkbox ID from HTML comment +- Add `dataCheckboxId` attribute instead of/alongside `dataCheckboxIndex` + +### Phase 4: UI Features + +#### 4.1 Update Button (Repeatable Page) +**File**: `src/client/pages/Repeatable.tsx` + +Add button that appears when repeatable's template version is not the latest: + +```typescript +const isLatestVersion = await checkIsLatestVersion(repeatable.template); + +// In render: +{!isLatestVersion && ( + +)} +``` + +**handleUpdateTemplate**: +1. Find latest version of the template +2. Call migration function to map values +3. Update repeatable's template reference +4. Save and refresh + +#### 4.2 Edit Button (Repeatable Page) +**File**: `src/client/pages/Repeatable.tsx` + +Add button that appears when repeatable uses the latest template version: + +```typescript +{isLatestVersion && ( + +)} +``` + +#### 4.3 Inline Edit Template Page +**File**: `src/client/pages/InlineTemplateEdit.tsx` (new) + +Route: `/template/:templateId/from/:repeatableId` + +Similar to Template.tsx but: +- Shows the repeatable's current checkbox values in preview (not empty array) +- Explicit save button (consistent with existing template editor) +- On save: creates new template version, migrates the source repeatable, redirects back to repeatable + +### Phase 5: URL Routing + +**File**: `src/client/App.tsx` + +```typescript + + {/* ... existing */} + } /> + +``` + +--- + +## Version Detection Logic + +```typescript +function getLatestTemplateVersion( + templateId: string, + db: Database +): Promise { + // templateId format: repeatable:template:: + const baseId = templateId.substring(0, templateId.lastIndexOf(':')); + + // Find all versions of this template + const versions = await db.find({ + selector: { + _id: { $gt: baseId, $lte: `${baseId}\uffff` }, + deleted: { $ne: true } + }, + limit: 1000 + }); + + // Return highest version number + return versions.docs.sort(byVersionDesc)[0] || null; +} + +function isLatestVersion(currentTemplateId: string, latestTemplate: TemplateDoc): boolean { + return currentTemplateId === latestTemplate._id; +} +``` + +--- + +## Value Migration Algorithm + +When migrating a repeatable to a new template version: + +```typescript +function migrateRepeatableValues( + currentValues: Record, + oldTemplate: TemplateDoc, + newTemplate: TemplateDoc +): Record { + const newValues: Record = {}; + + // Get checkbox IDs from new template + const newCheckboxIds = parseCheckboxIds(newTemplate.markdown); + + for (const { id } of newCheckboxIds) { + if (id in currentValues) { + // Checkbox exists in both - preserve state + newValues[id] = currentValues[id]; + } else { + // New checkbox - use template default + const defaultValue = newTemplate.values.find(v => v.id === id); + newValues[id] = defaultValue?.default ?? false; + } + } + + // Checkboxes in currentValues but not in newTemplate are dropped + // (they were removed from the template) + + return newValues; +} +``` + +--- + +## Test Plan + +### Unit Tests + +#### Migration Tests +**File**: `src/client/features/Migration/migrateTemplate.test.ts` + +```typescript +describe('migrateTemplate', () => { + it('assigns unique IDs to each checkbox'); + it('preserves default values during migration'); + it('handles nested checkboxes'); + it('handles templates with no checkboxes'); + it('is idempotent (migrating twice produces same result)'); +}); +``` + +**File**: `src/client/features/Migration/migrateRepeatable.test.ts` + +```typescript +describe('migrateRepeatable', () => { + it('maps positional values to checkbox IDs'); + it('handles mismatched value count (fewer values than checkboxes)'); + it('handles mismatched value count (more values than checkboxes)'); + it('preserves checked state during migration'); +}); +``` + +#### Checkbox ID Parsing Tests +**File**: `src/client/features/Repeatable/checkboxIds.test.ts` + +```typescript +describe('parseCheckboxIds', () => { + it('extracts IDs from HTML comments'); + it('returns IDs in document order'); + it('handles checkboxes without IDs'); +}); + +describe('ensureCheckboxIds', () => { + it('adds IDs to checkboxes without them'); + it('preserves existing IDs'); + it('generates unique IDs'); +}); +``` + +#### Value Migration Tests +**File**: `src/client/features/Repeatable/migrateValues.test.ts` + +```typescript +describe('migrateRepeatableValues', () => { + it('preserves values for unchanged checkboxes'); + it('uses defaults for newly added checkboxes'); + it('drops values for removed checkboxes'); + it('handles complete checkbox replacement'); + it('handles reordered checkboxes (IDs stay same, order changes)'); +}); +``` + +### Integration Tests + +#### Update Flow Test +**File**: `src/client/pages/Repeatable.test.tsx` + +```typescript +describe('Repeatable page', () => { + it('shows update button when template has newer version'); + it('shows edit button when template is latest version'); + it('hides both buttons when repeatable is completed'); + + describe('update button', () => { + it('migrates values to latest template version'); + it('preserves checked state for unchanged checkboxes'); + it('updates template reference'); + }); +}); +``` + +#### Inline Edit Flow Test +**File**: `src/client/pages/InlineTemplateEdit.test.tsx` + +```typescript +describe('InlineTemplateEdit page', () => { + it('loads template and repeatable'); + it('shows repeatable values in preview'); + it('creates new template version on save'); + it('migrates repeatable to new version on save'); + it('redirects back to repeatable after save'); + it('handles adding new checkboxes'); + it('handles removing checkboxes'); + it('handles reordering checkboxes'); +}); +``` + +#### Migration Screen Test +**File**: `src/client/features/Migration/MigrationScreen.test.tsx` + +```typescript +describe('MigrationScreen', () => { + it('shows progress during migration'); + it('completes and dismisses on success'); + it('shows error and retry on failure'); + it('prevents interaction while migrating'); +}); +``` + +### E2E Test Scenarios + +1. **New user flow**: Create template → create repeatable → check items → edit template inline → verify checked items preserved +2. **Update flow**: Create template v1 → create repeatable → edit template separately (creates v2) → update repeatable → verify migration +3. **Migration flow**: Seed database with legacy format → load app → verify migration screen → verify data converted correctly + +--- + +## Implementation Order + +1. **Types and migration infrastructure** (Phase 1) + - Update types + - Create migration utilities + - Create migration screen + +2. **Migration logic** (Phase 2) + - Template migration function + - Repeatable migration function + - Database migration orchestration + +3. **Runtime updates** (Phase 3) + - Update checkbox rendering + - Update toggle handlers + - Update rehype plugin + +4. **UI features** (Phase 4) + - Update button + - Edit button + - Inline edit page + +5. **Testing** (Throughout) + - Unit tests for each component + - Integration tests for flows + - Manual E2E testing + +--- + +## Design Decisions + +| Question | Decision | +|----------|----------| +| What happens if a user edits markdown manually and removes an ID comment? | Treat as new checkbox, regenerate ID on next save | +| Should the update button require confirmation? | No - it preserves state; only adds defaults for new items | +| What icon for the update button? | `` from MUI (or similar update icon) | +| Should we show what changed when updating? | V1: update silently. V2: could add changelog | +| Auto-save or explicit save for inline edit? | Explicit save - consistent with existing template editor | +| Block app during migration? | Yes - full-screen overlay until complete | + +--- + +## Risk Mitigation + +1. **Data loss during migration**: + - Backup step before migration + - Migration is additive (converts format, doesn't delete) + - Thorough testing with real-world data shapes + +2. **Performance on large databases**: + - Batch updates + - Progress indication + - Tested with 1000+ documents + +3. **Sync conflicts during migration**: + - Pause sync during migration + - Or: migrate only local docs, let server sync bring in already-migrated versions diff --git a/package.json b/package.json index 42e8363..1cc807a 100644 --- a/package.json +++ b/package.json @@ -8,7 +8,7 @@ ], "scripts": { "start": "node dist/server", - "start:local": "docker compose up --wait --detach && yarn build && DATABASE_URL=postgres://postgres:postgres@localhost:15432 PORT=5000 node dist/server", + "start:local": "yarn build && DATABASE_URL=postgres://postgres:postgres@localhost:15432 PORT=5000 node dist/server", "psql": "psql postgres://postgres:postgres@localhost:15432", "biome": "biome check .", "clean": "rm -r dist .parcel-cache 2> /dev/null || true", @@ -106,6 +106,7 @@ "react-redux": "9.2.0", "react-router-dom": "^7.12.0", "react-test-renderer": "19.2.3", + "rehype-raw": "^7.0.0", "socket.io-client": "4.8.3", "supertest": "^7.2.2", "ts-node": "^10.9.2", diff --git a/pre-plan.md b/pre-plan.md new file mode 100644 index 0000000..57b98b3 --- /dev/null +++ b/pre-plan.md @@ -0,0 +1,56 @@ +**Let's write a plan** to support editing the template used while editing a repeatable, and having that repeatble use that new version. + +Currently you can edit a template as a separate action, and you can create a repeatble instance and check boxes. That repeatable will be attached to a specific version of the template. If you edit a template existing repeatables won't get that new template, only newly created repeatables, and there is no way to upgrade a repeatable instance to the newest template version. + +# User facing feature summary + +- We want to add a button beside the delete button when editing repeatables that is EITHER an edit button or an update button, displayed as either a pencil icon for edit (used elsewhere), or an update icon. +- IF the repeatble you are viewing doesn't have the newest version of the template, you see the update button, which let's you update this repeatable to the latest template +- IF the repeatable is on the latest version of the template, you see the edit button, which let's you edit the template, and will automatically update the repeatable you were editing upon save of editing the template + +# Important complex feature: intelligent template migration + +- templates have an array of false booleans that are duplicated when creating a repeatable instance. +- What happens if we add or remove a checkbox? How will the repeatable, when it's migrated, know where to put the true booleans it already has? For example, if the repeatable instance is. +``` +repeatable +- [x] first check +- [x] second check +- [ ] third unchecked +``` +and then we migrate the template to be +``` +repeatable +- [ ] first check +- [ ] check in the middle +- [ ] second check +- [ ] third unchecked +``` +We'd want, when saving this new template with the extra field, so keep the 'first check' and 'second check' checkboxes checked. + +Let's think about how we can deal with this. Come up with three options, with their pros and cons. Some options I can think of you are free to dismiss: + +- when creating a checkbox somehow give it a unique id, with new checkboxes getting new ids when updating a repeatable, and so instead of storing an array of booleans it's a map +- when migrating a repeatable, inspect the contents of the markdown and use fuzzy matching on the label beside the checkbox to probabalistily determine what should stay checked. + +Feel free to search the internet for ideas. + +Note that if you wish to change data structures we will also need a migration strategy to run on startup for the data stored in PouchDB, which will require a progress bar screen that sits atop the app until it's done. + +# User facing feature details + +This presumes we have some migration strategy as discussed above. + +## Update button + +This will show if the repeatble is not on the latest template version. Clicking it performs the migration described above, which will then update state and, now that the template version is the latest, show the edit button + +## Edit button + +This will show if the repeatable is on the latest version of the template. Clicking it lets you edit the template, with the current repeatable in context. So, if you have some checkboxes checked, they will show in the preview as checked. Look at the existing URL structure and use a new URL that makes sense given the others. When you save the template, the repeatable will be migrated, and you will be redirected back to the normal repeatable view. + +Consider this, write a plan, including tests. + +If you have any questions, let me know and we will workshop this plan. + +Store it in inline-edit.md. diff --git a/src/client/App.test.tsx b/src/client/App.test.tsx index 349431b..a6acd1f 100644 --- a/src/client/App.test.tsx +++ b/src/client/App.test.tsx @@ -37,6 +37,8 @@ describe('App Routing', () => { // biome-ignore lint/suspicious/noDocumentCookie: Required for dual-cookie auth testing document.cookie = CLIENT_COOKIE; + handle.allDocs.mockResolvedValue({ rows: [], total_rows: 0, offset: 0 }); + // Mock database responses for Home page // Home component makes multiple find() calls with different selectors, // so we need mockImplementation to handle each query pattern @@ -86,21 +88,23 @@ describe('App Routing', () => { const mockRepeatable: RepeatableDoc = { _id: 'test-id', template: 'repeatable:template:test', - values: [], + values: {}, created: Date.now(), updated: Date.now(), slug: '', + schemaVersion: 2, }; const mockTemplate: TemplateDoc = { _id: 'repeatable:template:test', title: 'Test Template', - markdown: '- [ ] Test item', + markdown: '- [ ] Test item ', slug: { type: SlugType.Date }, created: Date.now(), updated: Date.now(), versioned: Date.now(), - values: [], + values: [{ id: 'cb1', default: false }], + schemaVersion: 2, }; // Repeatable page calls handle.get with the route param (just 'test-id') @@ -141,6 +145,7 @@ describe('App Routing', () => { created: Date.now(), updated: Date.now(), versioned: Date.now(), + schemaVersion: 2, }; // biome-ignore lint/suspicious/noExplicitAny: PouchDB.get has overloaded signatures; using any for vitest-when compatibility diff --git a/src/client/App.tsx b/src/client/App.tsx index 89bac9c..ab83c27 100644 --- a/src/client/App.tsx +++ b/src/client/App.tsx @@ -3,6 +3,7 @@ import './App.scss'; import { Typography } from '@mui/material'; import { lazy, Suspense } from 'react'; import { Route, Routes } from 'react-router-dom'; +import { MigrationProvider } from './features/Migration'; import Page from './features/Page/Page'; import SyncManager from './features/Sync/SyncManager'; import UpdateManager from './features/Update/UpdateManager'; @@ -25,23 +26,27 @@ function App() { {process.env.NODE_ENV !== 'development' && } - - - }> - - } /> - } /> - } /> - } /> - } /> - - - - {isGuest && ( - - Data only stored in this browser. Create an account to enable access from other devices. - - )} + + + + }> + + } /> + } /> + } /> + } /> + } /> + } /> + + + + {isGuest && ( + + Data only stored in this browser. Create an account to enable access from other + devices. + + )} + ); diff --git a/src/client/db/0.0.4.ts b/src/client/db/0.0.4.ts index d1d5a26..e9839bb 100644 --- a/src/client/db/0.0.4.ts +++ b/src/client/db/0.0.4.ts @@ -6,6 +6,8 @@ const doc = { title: 'Click Me First', slug: { type: 'date' }, created: Date.now(), + schemaVersion: 2, + values: [{ id: 'abc', default: false }], markdown: `# Hello There! Hello and welcome to my not-well-documented experiment in repeatable checklists! @@ -29,7 +31,7 @@ You create a template for each set of steps, and use that template every time yo Templates are written in [Markdown](https://commonmark.org/help/), with the syntax addition of checkboxes by starting a new line with the \` - []\` tag. -- [ ] here is a checkbox you can check! +- [ ] here is a checkbox you can check! If you go back to the main page you can edit this template to see an example. diff --git a/src/client/db/__mocks__/index.ts b/src/client/db/__mocks__/index.ts index 162d679..699a406 100644 --- a/src/client/db/__mocks__/index.ts +++ b/src/client/db/__mocks__/index.ts @@ -2,22 +2,24 @@ import { type Mock, vi } from 'vitest'; import type { Doc } from '../../../shared/types'; export interface MockDatabase { + allDocs: Mock< + (options?: unknown) => Promise<{ rows: unknown[]; total_rows: number; offset: number }> + >; find: Mock<(options?: unknown) => Promise<{ docs: unknown[] }>>; get: Mock<(docId: string) => Promise>; userPut: Mock<(doc: Doc) => Promise>; info: Mock<() => Promise>>; changes: Mock<(options?: unknown) => Promise<{ results: unknown[] }>>; - allDocs: Mock<(options?: unknown) => Promise<{ rows: unknown[] }>>; bulkDocs: Mock<(docs: Doc[], options?: unknown) => Promise>; } const mockDb: MockDatabase = { + allDocs: vi.fn(), find: vi.fn(), get: vi.fn(), userPut: vi.fn(), info: vi.fn(), changes: vi.fn(), - allDocs: vi.fn(), bulkDocs: vi.fn(), }; diff --git a/src/client/db/index.ts b/src/client/db/index.ts index 7d2c4a1..357de98 100644 --- a/src/client/db/index.ts +++ b/src/client/db/index.ts @@ -16,8 +16,8 @@ PouchDB.plugin(Find); * Old databases were named `sanremo-${username}` (with PouchDB prefix `_pouch_`). * New databases are named `sanremo-2.0-${username}`. */ +// FIXME: use a metadata document (same as migrating documents to version two) async function cleanupOldDatabases(): Promise { - // indexedDB.databases() is not available in all browsers, but is in modern ones if (!indexedDB.databases) { return; } diff --git a/src/client/db/setup.ts b/src/client/db/setup.ts index abf0507..b56e02e 100644 --- a/src/client/db/setup.ts +++ b/src/client/db/setup.ts @@ -1,5 +1,6 @@ import migrate004 from './0.0.4'; +// FIXME: make setup blocking, move the Migration feature logic into happening here, put setup behind the progress bar export default function setup(db: PouchDB.Database) { db.createIndex({ index: { diff --git a/src/client/features/Migration/MigrationProvider.tsx b/src/client/features/Migration/MigrationProvider.tsx new file mode 100644 index 0000000..67d12ac --- /dev/null +++ b/src/client/features/Migration/MigrationProvider.tsx @@ -0,0 +1,36 @@ +import type { FC, ReactNode } from 'react'; +import { useState } from 'react'; +import db from '../../db'; +import { useSelector } from '../../store'; +import MigrationScreen from './MigrationScreen'; + +type MigrationProviderProps = { + children: ReactNode; +}; + +/** + * Provider component that checks for and performs database migration on startup. + * Blocks the app with a full-screen overlay until migration is complete. + * + * Must be rendered inside UserProvider (needs user state to access database). + */ +const MigrationProvider: FC = ({ children }) => { + const user = useSelector((state) => state.user.value); + const [migrationComplete, setMigrationComplete] = useState(false); + + // User should always be defined here since we're inside UserProvider + // but guard against it just in case + if (!user) { + return null; + } + + const handle = db(user); + + if (!migrationComplete) { + return setMigrationComplete(true)} />; + } + + return <>{children}; +}; + +export default MigrationProvider; diff --git a/src/client/features/Migration/MigrationScreen.tsx b/src/client/features/Migration/MigrationScreen.tsx new file mode 100644 index 0000000..7e2cab4 --- /dev/null +++ b/src/client/features/Migration/MigrationScreen.tsx @@ -0,0 +1,137 @@ +import { Box, LinearProgress, Typography } from '@mui/material'; +import { useCallback, useEffect, useState } from 'react'; +import type { MigrationProgress } from './migrateDocs'; +import { migrateDatabase, needsMigration } from './migrateDocs'; + +export type MigrationScreenProps = { + db: PouchDB.Database; + onComplete: () => void; +}; + +/** + * Full-screen overlay that blocks the app until migration completes. + * Shows progress bar and handles errors with retry capability. + */ +function MigrationScreen({ db, onComplete }: MigrationScreenProps) { + const [checking, setChecking] = useState(true); + const [migrating, setMigrating] = useState(false); + const [progress, setProgress] = useState(null); + const [error, setError] = useState(null); + + const handleProgress = useCallback((p: MigrationProgress) => { + setProgress(p); + }, []); + + const runMigration = useCallback(async () => { + setError(null); + setMigrating(true); + + try { + await migrateDatabase(db, handleProgress); + onComplete(); + } catch (err) { + console.error('Migration failed:', err); + setError(err instanceof Error ? err.message : 'Migration failed'); + setMigrating(false); + } + }, [db, handleProgress, onComplete]); + + useEffect(() => { + async function checkAndMigrate() { + try { + const needs = await needsMigration(db); + setChecking(false); + + if (needs) { + await runMigration(); + } else { + onComplete(); + } + } catch (err) { + console.error('Migration check failed:', err); + setError(err instanceof Error ? err.message : 'Failed to check migration status'); + setChecking(false); + } + } + + checkAndMigrate(); + }, [db, onComplete, runMigration]); + + // Calculate progress percentage + let progressPercent = 0; + let progressText = 'Checking database...'; + + // FIXME: migration screen shouldn't understand internals of progress like this + if (progress) { + if (progress.phase === 'templates') { + progressPercent = (progress.current / progress.total) * 50; + progressText = `Migrating templates: ${progress.current} / ${progress.total}`; + } else { + progressPercent = 50 + (progress.current / progress.total) * 50; + progressText = `Migrating repeatables: ${progress.current} / ${progress.total}`; + } + } else if (migrating) { + progressText = 'Starting migration...'; + } + + return ( + + + Updating Database + + + {checking && Checking database version...} + + {migrating && !error && ( + <> + + + + + {progressText} + + + )} + + {error && ( + <> + + {error} + + + Retry + + + )} + + ); +} + +export default MigrationScreen; diff --git a/src/client/features/Migration/index.ts b/src/client/features/Migration/index.ts new file mode 100644 index 0000000..2e331df --- /dev/null +++ b/src/client/features/Migration/index.ts @@ -0,0 +1,2 @@ +export { default as MigrationProvider } from './MigrationProvider'; +export { default as MigrationScreen } from './MigrationScreen'; diff --git a/src/client/features/Migration/migrateDocs.ts b/src/client/features/Migration/migrateDocs.ts new file mode 100644 index 0000000..1b6f233 --- /dev/null +++ b/src/client/features/Migration/migrateDocs.ts @@ -0,0 +1,104 @@ +import type { LegacyRepeatableDoc, LegacyTemplateDoc, TemplateDoc } from '../../../shared/types'; +import { isLegacyRepeatable, migrateRepeatable } from './migrateRepeatable'; +import { isLegacyTemplate, migrateTemplate } from './migrateTemplate'; + +export type MigrationProgress = { + phase: 'templates' | 'repeatables'; + current: number; + total: number; +}; + +export type MigrationCallback = (progress: MigrationProgress) => void; + +/** + * Check if the database needs migration by looking for any legacy documents. + */ +// FIXME: instead of using all docs, use a metadata document that we can store whether the migration has occurred +export async function needsMigration(db: PouchDB.Database): Promise { + const allDocs = await db.allDocs({ include_docs: true }); + + for (const row of allDocs.rows) { + if (isLegacyTemplate(row.doc) || isLegacyRepeatable(row.doc)) { + return true; + } + } + + return false; +} + +/** + * Migrate all legacy documents in the database to the new schema. + * + * Process: + * 1. Migrate templates first (repeatables depend on them) + * 2. Then migrate repeatables using the migrated templates + * 3. Report progress via callback + * + * @param db - The PouchDB database instance + * @param onProgress - Optional callback for progress updates + */ +export async function migrateDatabase( + db: PouchDB.Database, + onProgress?: MigrationCallback, +): Promise { + const allDocs = await db.allDocs({ include_docs: true }); + + const legacyTemplates: LegacyTemplateDoc[] = []; + const legacyRepeatables: LegacyRepeatableDoc[] = []; + + for (const row of allDocs.rows) { + if (isLegacyTemplate(row.doc)) { + legacyTemplates.push(row.doc); + } else if (isLegacyRepeatable(row.doc)) { + legacyRepeatables.push(row.doc); + } + } + + const totalTemplates = legacyTemplates.length; + const totalRepeatables = legacyRepeatables.length; + + const migratedTemplates = new Map(); + + // Phase 1: Migrate templates + for (let i = 0; i < legacyTemplates.length; i++) { + const template = legacyTemplates[i]; + const migrated = migrateTemplate(template); + + // Save the migrated template + await db.put(migrated); + migratedTemplates.set(migrated._id, migrated); + + onProgress?.({ + phase: 'templates', + current: i + 1, + total: totalTemplates, + }); + } + + // Phase 2: Migrate repeatables + for (let i = 0; i < legacyRepeatables.length; i++) { + const repeatable = legacyRepeatables[i]; + + let template = migratedTemplates.get(repeatable.template); + if (!template) { + // Template wasn't in the migration set - fetch it from DB + // It may already be schema version 2 + try { + template = await db.get(repeatable.template); + } catch { + // Template not found - skip this repeatable + console.warn(`Template ${repeatable.template} not found for repeatable ${repeatable._id}`); + continue; + } + } + + const migrated = migrateRepeatable(repeatable, template); + await db.put(migrated); + + onProgress?.({ + phase: 'repeatables', + current: i + 1, + total: totalRepeatables, + }); + } +} diff --git a/src/client/features/Migration/migrateRepeatable.test.ts b/src/client/features/Migration/migrateRepeatable.test.ts new file mode 100644 index 0000000..5b3f08c --- /dev/null +++ b/src/client/features/Migration/migrateRepeatable.test.ts @@ -0,0 +1,194 @@ +import { describe, expect, it } from 'vitest'; +import { type LegacyRepeatableDoc, SlugType, type TemplateDoc } from '../../../shared/types'; +import { isLegacyRepeatable, migrateRepeatable } from './migrateRepeatable'; + +function createLegacyRepeatable(overrides: Partial = {}): LegacyRepeatableDoc { + return { + _id: 'repeatable:instance:test-uuid', + template: 'repeatable:template:tmpl-uuid:1', + slug: Date.now(), + created: Date.now(), + updated: Date.now(), + values: [true, false, true], + ...overrides, + }; +} + +function createMigratedTemplate( + markdown: string, + values: Array<{ id: string; default: boolean }>, +): TemplateDoc { + return { + _id: 'repeatable:template:tmpl-uuid:1', + title: 'Test Template', + slug: { type: SlugType.Timestamp }, + markdown, + created: Date.now(), + updated: Date.now(), + versioned: Date.now(), + values, + schemaVersion: 2, + }; +} + +describe('isLegacyRepeatable', () => { + it('returns true for legacy repeatable', () => { + const repeatable = createLegacyRepeatable(); + expect(isLegacyRepeatable(repeatable)).toBe(true); + }); + + it('returns false for migrated repeatable', () => { + const repeatable = { + ...createLegacyRepeatable(), + schemaVersion: 2, + values: { abc: true, def: false }, + }; + expect(isLegacyRepeatable(repeatable)).toBe(false); + }); + + it('returns false for non-repeatable documents', () => { + expect(isLegacyRepeatable(null)).toBe(false); + expect(isLegacyRepeatable(undefined)).toBe(false); + expect(isLegacyRepeatable({ _id: 'repeatable:template:test:1' })).toBe(false); + expect(isLegacyRepeatable({ _id: 'other:document' })).toBe(false); + }); + + it('returns true for repeatable with empty values array', () => { + const repeatable = createLegacyRepeatable({ values: [] }); + expect(isLegacyRepeatable(repeatable)).toBe(true); + }); +}); + +describe('migrateRepeatable', () => { + it('maps positional values to checkbox IDs', () => { + const template = createMigratedTemplate( + '- [ ] Task 1 \n- [ ] Task 2 \n- [ ] Task 3 ', + [ + { id: 'abc', default: false }, + { id: 'def', default: false }, + { id: 'ghi', default: false }, + ], + ); + + const repeatable = createLegacyRepeatable({ + values: [true, false, true], + }); + + const migrated = migrateRepeatable(repeatable, template); + + expect(migrated.values).toEqual({ + abc: true, + def: false, + ghi: true, + }); + }); + + it('handles mismatched value count (fewer values than checkboxes)', () => { + const template = createMigratedTemplate( + '- [ ] Task 1 \n- [ ] Task 2 \n- [ ] Task 3 ', + [ + { id: 'abc', default: false }, + { id: 'def', default: true }, + { id: 'ghi', default: false }, + ], + ); + + const repeatable = createLegacyRepeatable({ + values: [true], // Only one value + }); + + const migrated = migrateRepeatable(repeatable, template); + + expect(migrated.values).toEqual({ + abc: true, // From repeatable + def: true, // From template default + ghi: false, // From template default + }); + }); + + it('handles mismatched value count (more values than checkboxes)', () => { + const template = createMigratedTemplate('- [ ] Task 1 ', [ + { id: 'abc', default: false }, + ]); + + const repeatable = createLegacyRepeatable({ + values: [true, false, true], // 3 values for 1 checkbox + }); + + const migrated = migrateRepeatable(repeatable, template); + + expect(migrated.values).toEqual({ + abc: true, + }); + }); + + it('preserves checked state during migration', () => { + const template = createMigratedTemplate( + '- [ ] A \n- [ ] B \n- [ ] C \n- [ ] D ', + [ + { id: 'a', default: false }, + { id: 'b', default: false }, + { id: 'c', default: false }, + { id: 'd', default: false }, + ], + ); + + const repeatable = createLegacyRepeatable({ + values: [true, true, false, false], + }); + + const migrated = migrateRepeatable(repeatable, template); + + expect(migrated.values.a).toBe(true); + expect(migrated.values.b).toBe(true); + expect(migrated.values.c).toBe(false); + expect(migrated.values.d).toBe(false); + }); + + it('sets schemaVersion to 2', () => { + const template = createMigratedTemplate('- [ ] Task ', [ + { id: 'abc', default: false }, + ]); + + const repeatable = createLegacyRepeatable({ values: [true] }); + const migrated = migrateRepeatable(repeatable, template); + + expect(migrated.schemaVersion).toBe(2); + }); + + it('preserves other repeatable properties', () => { + const template = createMigratedTemplate('- [ ] Task ', [ + { id: 'abc', default: false }, + ]); + + const repeatable = createLegacyRepeatable({ + _id: 'repeatable:instance:my-uuid', + _rev: '5-xyz789', + template: 'repeatable:template:tmpl:3', + slug: 'my-slug', + created: 1111111111, + updated: 2222222222, + completed: 3333333333, + values: [true], + }); + + const migrated = migrateRepeatable(repeatable, template); + + expect(migrated._id).toBe('repeatable:instance:my-uuid'); + expect(migrated._rev).toBe('5-xyz789'); + expect(migrated.template).toBe('repeatable:template:tmpl:3'); + expect(migrated.slug).toBe('my-slug'); + expect(migrated.created).toBe(1111111111); + expect(migrated.updated).toBe(2222222222); + expect(migrated.completed).toBe(3333333333); + }); + + it('handles empty values array', () => { + const template = createMigratedTemplate('# No checkboxes', []); + + const repeatable = createLegacyRepeatable({ values: [] }); + const migrated = migrateRepeatable(repeatable, template); + + expect(migrated.values).toEqual({}); + }); +}); diff --git a/src/client/features/Migration/migrateRepeatable.ts b/src/client/features/Migration/migrateRepeatable.ts new file mode 100644 index 0000000..4866dcd --- /dev/null +++ b/src/client/features/Migration/migrateRepeatable.ts @@ -0,0 +1,57 @@ +import type { LegacyRepeatableDoc, RepeatableDoc, TemplateDoc } from '../../../shared/types'; +import { parseCheckboxIds } from '../Template/checkboxIds'; + +/** + * Check if a document is a legacy repeatable (schema version 1). + */ +export function isLegacyRepeatable(doc: unknown): doc is LegacyRepeatableDoc { + if (!doc || typeof doc !== 'object') return false; + const d = doc as Record; + return ( + typeof d._id === 'string' && + d._id.startsWith('repeatable:instance:') && + d.schemaVersion === undefined && + Array.isArray(d.values) && + (d.values.length === 0 || typeof d.values[0] === 'boolean') + ); +} + +/** + * Migrate a legacy repeatable to the new schema. + * + * 1. Get checkbox IDs from template (in document order) + * 2. Map positional values to ID-keyed record + * 3. Set schemaVersion: 2 + * + * @param repeatable - The legacy repeatable to migrate + * @param template - The already-migrated template this repeatable references + */ +export function migrateRepeatable( + repeatable: LegacyRepeatableDoc, + template: TemplateDoc, +): RepeatableDoc { + // Get checkbox IDs from the migrated template's markdown + const checkboxInfos = parseCheckboxIds(template.markdown); + + // Convert boolean[] to Record + // Map positional values to the checkbox IDs + const migratedValues: Record = {}; + + for (let i = 0; i < checkboxInfos.length; i++) { + const info = checkboxInfos[i]; + // Use the positional value if available, otherwise use template default + if (i < repeatable.values.length) { + migratedValues[info.id] = repeatable.values[i]; + } else { + // Find the template default for this checkbox + const templateValue = template.values.find((v) => v.id === info.id); + migratedValues[info.id] = templateValue?.default ?? false; + } + } + + return { + ...repeatable, + values: migratedValues, + schemaVersion: 2, + }; +} diff --git a/src/client/features/Migration/migrateTemplate.test.ts b/src/client/features/Migration/migrateTemplate.test.ts new file mode 100644 index 0000000..de8d1a7 --- /dev/null +++ b/src/client/features/Migration/migrateTemplate.test.ts @@ -0,0 +1,226 @@ +import { describe, expect, it } from 'vitest'; +import { type LegacyTemplateDoc, SlugType } from '../../../shared/types'; +import { isLegacyTemplate, migrateTemplate } from './migrateTemplate'; + +function createLegacyTemplate(overrides: Partial = {}): LegacyTemplateDoc { + return { + _id: 'repeatable:template:test-uuid:1', + title: 'Test Template', + slug: { type: SlugType.Timestamp }, + markdown: '- [ ] Task 1\n- [ ] Task 2', + created: Date.now(), + updated: Date.now(), + versioned: Date.now(), + values: [false, true], + ...overrides, + }; +} + +describe('isLegacyTemplate', () => { + it('returns true for legacy template', () => { + const template = createLegacyTemplate(); + expect(isLegacyTemplate(template)).toBe(true); + }); + + it('returns false for migrated template', () => { + const template = { + ...createLegacyTemplate(), + schemaVersion: 2, + values: [{ id: 'abc', default: false }], + }; + expect(isLegacyTemplate(template)).toBe(false); + }); + + it('returns false for non-template documents', () => { + expect(isLegacyTemplate(null)).toBe(false); + expect(isLegacyTemplate(undefined)).toBe(false); + expect(isLegacyTemplate({ _id: 'repeatable:instance:test' })).toBe(false); + expect(isLegacyTemplate({ _id: 'other:document' })).toBe(false); + }); + + it('returns true for template with empty values array', () => { + const template = createLegacyTemplate({ values: [] }); + expect(isLegacyTemplate(template)).toBe(true); + }); + + it('returns true for template with no values property (like 0.0.4 template)', () => { + // The original 0.0.4 template had no values property at all + const template = { + _id: 'repeatable:template:0.0.4:2', + title: 'Click Me First', + slug: { type: 'date' }, + created: Date.now(), + markdown: '- [ ] here is a checkbox you can check!', + // No values property + // No schemaVersion + }; + expect(isLegacyTemplate(template)).toBe(true); + }); +}); + +describe('migrateTemplate', () => { + it('assigns unique IDs to each checkbox', () => { + const template = createLegacyTemplate({ + markdown: '- [ ] Task 1\n- [ ] Task 2\n- [ ] Task 3', + values: [false, true, false], + }); + + const migrated = migrateTemplate(template); + + // Should have 3 checkbox values with unique IDs + expect(migrated.values).toHaveLength(3); + const ids = migrated.values.map((v) => v.id); + expect(new Set(ids).size).toBe(3); + }); + + it('preserves default values during migration', () => { + const template = createLegacyTemplate({ + markdown: '- [ ] Unchecked\n- [x] Checked\n- [ ] Also unchecked', + values: [false, true, false], + }); + + const migrated = migrateTemplate(template); + + expect(migrated.values[0].default).toBe(false); + expect(migrated.values[1].default).toBe(true); + expect(migrated.values[2].default).toBe(false); + }); + + it('handles nested checkboxes', () => { + const template = createLegacyTemplate({ + markdown: '- [ ] Parent\n - [ ] Child 1\n - [ ] Child 2', + values: [true, false, true], + }); + + const migrated = migrateTemplate(template); + + expect(migrated.values).toHaveLength(3); + expect(migrated.values[0].default).toBe(true); + expect(migrated.values[1].default).toBe(false); + expect(migrated.values[2].default).toBe(true); + }); + + it('handles templates with no checkboxes', () => { + const template = createLegacyTemplate({ + markdown: '# Just a header\nSome text', + values: [], + }); + + const migrated = migrateTemplate(template); + + expect(migrated.values).toHaveLength(0); + expect(migrated.schemaVersion).toBe(2); + }); + + it('is idempotent (migrating twice produces same result)', () => { + const template = createLegacyTemplate({ + markdown: '- [ ] Task 1\n- [ ] Task 2', + values: [false, true], + }); + + const firstMigration = migrateTemplate(template); + + // Create a "legacy" version from the first migration by removing schemaVersion + const pseudoLegacy = { + ...firstMigration, + schemaVersion: undefined, + values: firstMigration.values.map((v) => v.default), + } as unknown as LegacyTemplateDoc; + + const secondMigration = migrateTemplate(pseudoLegacy); + + // The markdown should be identical (IDs preserved) + expect(secondMigration.markdown).toBe(firstMigration.markdown); + }); + + it('sets schemaVersion to 2', () => { + const template = createLegacyTemplate(); + const migrated = migrateTemplate(template); + + expect(migrated.schemaVersion).toBe(2); + }); + + it('embeds IDs in markdown as HTML comments', () => { + const template = createLegacyTemplate({ + markdown: '- [ ] Task 1\n- [ ] Task 2', + values: [false, false], + }); + + const migrated = migrateTemplate(template); + + // Markdown should contain checkbox ID comments + expect(migrated.markdown).toMatch(//); + // Should have 2 ID comments + const matches = migrated.markdown.match(//g); + expect(matches).toHaveLength(2); + }); + + it('preserves other template properties', () => { + const template = createLegacyTemplate({ + _id: 'repeatable:template:my-uuid:5', + _rev: '3-abc123', + title: 'My Special Template', + slug: { type: SlugType.String, placeholder: 'Enter name' }, + created: 1234567890, + updated: 1234567899, + versioned: 1234567895, + }); + + const migrated = migrateTemplate(template); + + expect(migrated._id).toBe('repeatable:template:my-uuid:5'); + expect(migrated._rev).toBe('3-abc123'); + expect(migrated.title).toBe('My Special Template'); + expect(migrated.slug).toEqual({ type: SlugType.String, placeholder: 'Enter name' }); + expect(migrated.created).toBe(1234567890); + expect(migrated.updated).toBe(1234567899); + expect(migrated.versioned).toBe(1234567895); + }); + + it('handles mismatched values count (fewer values than checkboxes)', () => { + const template = createLegacyTemplate({ + markdown: '- [ ] Task 1\n- [ ] Task 2\n- [ ] Task 3', + values: [true], // Only one value for 3 checkboxes + }); + + const migrated = migrateTemplate(template); + + expect(migrated.values).toHaveLength(3); + expect(migrated.values[0].default).toBe(true); + expect(migrated.values[1].default).toBe(false); // Defaults to false + expect(migrated.values[2].default).toBe(false); + }); + + it('handles mismatched values count (more values than checkboxes)', () => { + const template = createLegacyTemplate({ + markdown: '- [ ] Task 1', + values: [true, false, true], // 3 values for 1 checkbox + }); + + const migrated = migrateTemplate(template); + + expect(migrated.values).toHaveLength(1); + expect(migrated.values[0].default).toBe(true); + }); + + it('handles templates with no values property (like 0.0.4 template)', () => { + // The original 0.0.4 template had no values property at all + const template = { + _id: 'repeatable:template:0.0.4:2', + title: 'Click Me First', + slug: { type: 'date' }, + created: Date.now(), + updated: Date.now(), + versioned: Date.now(), + markdown: '- [ ] here is a checkbox you can check!', + // No values property - this is the key difference + } as unknown as LegacyTemplateDoc; + + const migrated = migrateTemplate(template); + + expect(migrated.schemaVersion).toBe(2); + expect(migrated.values).toHaveLength(1); + expect(migrated.values[0].default).toBe(false); // Defaults to false when no value exists + expect(migrated.markdown).toMatch(//); + }); +}); diff --git a/src/client/features/Migration/migrateTemplate.ts b/src/client/features/Migration/migrateTemplate.ts new file mode 100644 index 0000000..9ff0a32 --- /dev/null +++ b/src/client/features/Migration/migrateTemplate.ts @@ -0,0 +1,59 @@ +import type { CheckboxValue, LegacyTemplateDoc, TemplateDoc } from '../../../shared/types'; +import { ensureCheckboxIds, parseCheckboxIds } from '../Template/checkboxIds'; + +/** + * Check if a document is a legacy template (schema version 1). + * + * Legacy templates have: + * - No schemaVersion field + * - Either no values field, or values as boolean[] instead of CheckboxValue[] + */ +export function isLegacyTemplate(doc: unknown): doc is LegacyTemplateDoc { + if (!doc || typeof doc !== 'object') return false; + const d = doc as Record; + + // Must be a template document without schema version + if (typeof d._id !== 'string' || !d._id.startsWith('repeatable:template:')) return false; + if (d.schemaVersion !== undefined) return false; + + // Legacy if values is missing/undefined + if (d.values === undefined) return true; + + // Legacy if values is boolean[] (empty array or first element is boolean) + if (Array.isArray(d.values)) { + return d.values.length === 0 || typeof d.values[0] === 'boolean'; + } + + return false; +} + +/** + * Migrate a legacy template to the new schema. + * + * 1. Parse markdown to find checkboxes + * 2. Assign UUID to each checkbox (via HTML comments) + * 3. Convert values array to CheckboxValue[] + * 4. Set schemaVersion: 2 + */ +export function migrateTemplate(template: LegacyTemplateDoc): TemplateDoc { + // Ensure all checkboxes have IDs embedded in the markdown + const migratedMarkdown = ensureCheckboxIds(template.markdown); + + // Parse the checkbox IDs from the updated markdown + const checkboxInfos = parseCheckboxIds(migratedMarkdown); + + // Convert boolean[] to CheckboxValue[] + // Map positional values to the checkbox IDs + // Use optional chaining since legacy templates may have no values array + const migratedValues: CheckboxValue[] = checkboxInfos.map((info, index) => ({ + id: info.id, + default: template.values?.[index] ?? false, + })); + + return { + ...template, + markdown: migratedMarkdown, + values: migratedValues, + schemaVersion: 2, + }; +} diff --git a/src/client/features/Repeatable/CheckboxContext.tsx b/src/client/features/Repeatable/CheckboxContext.tsx index 2d96809..3b9fdf7 100644 --- a/src/client/features/Repeatable/CheckboxContext.tsx +++ b/src/client/features/Repeatable/CheckboxContext.tsx @@ -1,21 +1,27 @@ import { createContext, useContext } from 'react'; export type CheckboxContextType = { - /** Array of checkbox values (true = checked, false = unchecked) */ - values: boolean[]; - /** Callback when a checkbox is toggled. If undefined, checkboxes are disabled. */ + /** Map of checkbox ID to checked state */ + values: Record; + /** + * Callback when a checkbox is toggled by index. If undefined, checkboxes are disabled. + * The parent component (RepeatableRenderer) handles translating index to checkbox ID. + */ onChange?: (idx: number) => void; /** Whether checkboxes are disabled (derived from onChange being undefined) */ disabled: boolean; - /** Register a button ref for focus management. Returns cleanup function. */ + /** Register a button ref for focus management. Uses positional index for focus order. */ registerButton: (idx: number, element: HTMLElement | null) => void; + /** Get checkbox ID at a given positional index */ + getCheckboxId: (idx: number) => string | undefined; }; const defaultContext: CheckboxContextType = { - values: [], + values: {}, onChange: undefined, disabled: true, registerButton: () => {}, + getCheckboxId: () => undefined, }; export const CheckboxContext = createContext(defaultContext); diff --git a/src/client/features/Repeatable/MarkdownTaskCheckbox.tsx b/src/client/features/Repeatable/MarkdownTaskCheckbox.tsx index a79e99e..184c8ef 100644 --- a/src/client/features/Repeatable/MarkdownTaskCheckbox.tsx +++ b/src/client/features/Repeatable/MarkdownTaskCheckbox.tsx @@ -9,6 +9,7 @@ type Props = { type?: string; // Added by rehypeCheckboxIndex plugin dataCheckboxIndex?: number; + dataCheckboxId?: string; }; /** @@ -23,18 +24,19 @@ export const MarkdownTaskCheckbox = React.memo((props: Props) => { // react-markdown passes data attributes with their camelCase names const idx = props.dataCheckboxIndex ?? (props['data-checkbox-index' as keyof Props] as number | undefined); - const { values, disabled } = useCheckboxContext(); + const checkboxId = + props.dataCheckboxId ?? (props['data-checkbox-id' as keyof Props] as string | undefined); + const { values, disabled, getCheckboxId } = useCheckboxContext(); - // Determine checkbox state - const isValidCheckbox = idx !== undefined; - const isChecked = isValidCheckbox ? (values[idx] ?? false) : false; - - // Only handle checkbox inputs with an index from our rehype plugin - if (!isValidCheckbox) { + if (idx === undefined) { // Fallback: render a standard input for non-task-list checkboxes return )} />; } + // Determine checkbox state - use the ID if available, fall back to getting ID from index + const id = checkboxId ?? getCheckboxId(idx); + const isChecked = id ? (values[id] ?? false) : false; + return ( {}; describe('Repeatable Renderer', () => { describe('basic rendering', () => { it('renders nothing at all', async () => { - render(); + render(); const list = await screen.findByRole('list'); expect(list.textContent).toBe(''); }); it('renders a block of markdown', async () => { - render(); + render(); await screen.findByText('hello there'); }); @@ -28,7 +28,7 @@ This is a paragraph with **bold** and *italic* text. - Regular list item 1 - Regular list item 2`; - render(); + render(); await screen.findByText('Heading'); await screen.findByText(/bold/); @@ -46,7 +46,7 @@ Some intro text - [ ] first checkbox`; render( - , + , ); await screen.findByText('Header'); @@ -64,12 +64,7 @@ Some text in between - [ ] second checkbox`; render( - , + , ); await screen.findByText('first checkbox'); @@ -86,7 +81,7 @@ Some text in between Some closing text`; render( - , + , ); await screen.findByText('the checkbox'); @@ -106,12 +101,7 @@ Middle text # End`; const { container } = render( - , + , ); // Verify all content is present @@ -142,7 +132,7 @@ Middle text NOOP()} />, ); @@ -156,8 +146,8 @@ Middle text render( '} + values={{ cb1: true }} onChange={() => NOOP()} />, ); @@ -172,8 +162,8 @@ Middle text render( '} + values={{}} onChange={onChange} />, ); @@ -183,7 +173,7 @@ Middle text fireEvent.click(cb); expect(onChange).toBeCalledTimes(1); - expect(onChange).toBeCalledWith(0); + expect(onChange).toBeCalledWith('cb1'); }); it('onChange fires to the right checkbox when it is clicked', async () => { @@ -191,8 +181,8 @@ Middle text render( \n- [ ] check me instead '} + values={{}} onChange={onChange} />, ); @@ -204,21 +194,21 @@ Middle text fireEvent.click(cbs[1]); expect(onChange).toBeCalledTimes(1); - expect(onChange).toBeCalledWith(1); + expect(onChange).toBeCalledWith('cb2'); }); it('renders multiple checkboxes with correct indices and labels', async () => { const onChange = vi.fn(); - const markdown = `- [ ] first -- [ ] second -- [ ] third -- [ ] fourth`; + const markdown = `- [ ] first +- [ ] second +- [ ] third +- [ ] fourth `; const { container } = render( , ); @@ -226,7 +216,7 @@ Middle text const checkboxes = (await screen.findAllByRole('checkbox')) as HTMLInputElement[]; expect(checkboxes).toHaveLength(4); - // Verify checked states match values array + // Verify checked states match values expect(checkboxes[0].checked).toBe(true); expect(checkboxes[1].checked).toBe(false); expect(checkboxes[2].checked).toBe(true); @@ -242,12 +232,12 @@ Middle text lastIdx = idx; } - // Click checkboxes and verify correct index is passed to onChange + // Click checkboxes and verify correct ID is passed to onChange fireEvent.click(checkboxes[2]); - expect(onChange).toBeCalledWith(2); + expect(onChange).toBeCalledWith('cb3'); fireEvent.click(checkboxes[0]); - expect(onChange).toBeCalledWith(0); + expect(onChange).toBeCalledWith('cb1'); }); it('renders checkbox with markdown formatting in label', async () => { @@ -255,7 +245,7 @@ Middle text , ); @@ -278,7 +268,7 @@ Middle text , ); @@ -293,7 +283,7 @@ Middle text , ); @@ -309,8 +299,10 @@ Middle text render( \n- [ ] two \n- [ ] three ' + } + values={{ cb1: true, cb2: false, cb3: true }} onChange={NOOP} />, ); @@ -325,12 +317,7 @@ Middle text it('renders checkbox with minimal label', async () => { // Note: remark-gfm requires at least some content after the checkbox marker render( - , + , ); const checkbox = await screen.findByRole('checkbox'); @@ -349,7 +336,7 @@ Middle text , @@ -366,8 +353,10 @@ Middle text const { container } = render( \n- [ ] second \n- [ ] third ' + } + values={{ cb1: true, cb2: true, cb3: false }} onChange={NOOP} takesFocus={true} />, @@ -385,7 +374,7 @@ Middle text , @@ -405,11 +394,13 @@ Middle text // doesn't update the values prop. The parent is responsible for updating values. // We use rerender to simulate the parent updating state after onChange fires. const onChange = vi.fn(); + const markdown = + '- [ ] first \n- [ ] second \n- [ ] third '; const { container, rerender } = render( , @@ -421,7 +412,7 @@ Middle text expect(buttons[0]).toHaveFocus(); }); - // Click the first checkbox - this calls onChange(0) but doesn't update values + // Click the first checkbox - this calls onChange('cb1') but doesn't update values const checkboxes = screen.getAllByRole('checkbox'); fireEvent.click(checkboxes[0]); @@ -429,8 +420,8 @@ Middle text rerender( , @@ -445,11 +436,12 @@ Middle text it('stays on same item when unchecking', async () => { const onChange = vi.fn(); + const markdown = '- [ ] first \n- [ ] second '; const { container, rerender } = render( , @@ -469,8 +461,8 @@ Middle text rerender( , @@ -491,7 +483,7 @@ Middle text , @@ -519,7 +511,7 @@ Middle text , @@ -539,8 +531,8 @@ Middle text render( \n- [ ] second '} + values={{ cb1: true, cb2: true }} onChange={NOOP} takesFocus={true} />, @@ -557,12 +549,13 @@ Middle text it('calls hasFocus callback with false when last checkbox is checked', async () => { const hasFocusCb = vi.fn(); const onChange = vi.fn(); + const markdown = '- [ ] only one '; const { rerender } = render( , @@ -580,8 +573,8 @@ Middle text rerender( , @@ -599,7 +592,7 @@ Middle text , @@ -621,12 +614,7 @@ Middle text - [ ] second`; render( - , + , ); const checkboxes = await screen.findAllByRole('checkbox'); @@ -638,7 +626,7 @@ Middle text , ); @@ -654,7 +642,7 @@ Middle text , ); @@ -670,7 +658,7 @@ Middle text , ); @@ -685,7 +673,7 @@ Middle text , ); @@ -705,12 +693,7 @@ Middle text - [ ] Task 2`; render( - , + , ); const checkboxes = await screen.findAllByRole('checkbox'); @@ -723,17 +706,17 @@ Middle text }); it('indexes nested checkboxes in document order', async () => { - const markdown = `- [ ] Task 1 - - [ ] Subtask 1.1 - - [ ] Subtask 1.2 -- [ ] Task 2`; + const markdown = `- [ ] Task 1 + - [ ] Subtask 1.1 + - [ ] Subtask 1.2 +- [ ] Task 2 `; // Values: Task 1 checked, Subtask 1.1 unchecked, Subtask 1.2 checked, Task 2 unchecked render( , ); @@ -745,51 +728,41 @@ Middle text expect(checkboxes[3]).not.toBeChecked(); // Task 2 }); - it('calls onChange with correct index for nested checkbox', async () => { - const markdown = `- [ ] Task 1 - - [ ] Subtask 1.1 - - [ ] Subtask 1.2 -- [ ] Task 2`; + it('calls onChange with correct ID for nested checkbox', async () => { + const markdown = `- [ ] Task 1 + - [ ] Subtask 1.1 + - [ ] Subtask 1.2 +- [ ] Task 2 `; const onChange = vi.fn(); render( - , + , ); const checkboxes = await screen.findAllByRole('checkbox'); // Click Subtask 1.2 (index 2) fireEvent.click(checkboxes[2]); - expect(onChange).toHaveBeenCalledWith(2); + expect(onChange).toHaveBeenCalledWith('cb3'); // Click Task 2 (index 3) fireEvent.click(checkboxes[3]); - expect(onChange).toHaveBeenCalledWith(3); + expect(onChange).toHaveBeenCalledWith('cb4'); }); - it('clicking nested checkbox label calls onChange with correct index', async () => { - const markdown = `- [ ] Task 1 - - [ ] Subtask 1.1 -- [ ] Task 2`; + it('clicking nested checkbox label calls onChange with correct ID', async () => { + const markdown = `- [ ] Task 1 + - [ ] Subtask 1.1 +- [ ] Task 2 `; const onChange = vi.fn(); render( - , + , ); - // Click on the label text for Subtask 1.1 (index 1) + // Click on the label text for Subtask 1.1 (ID cb2) fireEvent.click(await screen.findByText('Subtask 1.1')); - expect(onChange).toHaveBeenCalledWith(1); + expect(onChange).toHaveBeenCalledWith('cb2'); }); // Helper to get the ListItemButton elements (focus is on the button, not the checkbox) @@ -797,16 +770,16 @@ Middle text Array.from(container.querySelectorAll('.MuiListItemButton-root')) as HTMLElement[]; it('focus advances through nested checkboxes in document order', async () => { - const markdown = `- [ ] Task 1 - - [ ] Subtask 1.1 -- [ ] Task 2`; + const markdown = `- [ ] Task 1 + - [ ] Subtask 1.1 +- [ ] Task 2 `; const onChange = vi.fn(); const { container, rerender } = render( , @@ -820,14 +793,14 @@ Middle text // Click Task 1 fireEvent.click(screen.getAllByRole('checkbox')[0]); - expect(onChange).toHaveBeenCalledWith(0); + expect(onChange).toHaveBeenCalledWith('cb1'); // Rerender with Task 1 checked rerender( , diff --git a/src/client/features/Repeatable/RepeatableRenderer.tsx b/src/client/features/Repeatable/RepeatableRenderer.tsx index b74dd6f..4586964 100644 --- a/src/client/features/Repeatable/RepeatableRenderer.tsx +++ b/src/client/features/Repeatable/RepeatableRenderer.tsx @@ -1,6 +1,7 @@ import { List } from '@mui/material'; import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import ReactMarkdown from 'react-markdown'; +import rehypeRaw from 'rehype-raw'; import remarkGfm from 'remark-gfm'; import type { PluggableList } from 'unified'; import { debugClient } from '../../globals'; @@ -15,8 +16,8 @@ const debugFocus = debugClient('repeatable', 'focus'); export type RepeatableProps = { markdown: string; - values: boolean[]; - onChange?: (idx: number) => void; + values: Record; + onChange?: (checkboxId: string) => void; /** whether the auto focus is inside the markdown document. Will never be called if takesFocus is false */ hasFocus?: (hasFocus: boolean) => void; /** whether we are in focus grabbing mode. Default false */ @@ -24,13 +25,17 @@ export type RepeatableProps = { }; /** - * Calculate the initial focus index based on the values array. + * Calculate the initial focus index based on the values and checkbox IDs. * Focus goes to the first unchecked checkbox, or past the end if all are checked. */ -function calculateInitialFocusIndex(values: boolean[]): number { +function calculateInitialFocusIndex( + values: Record, + checkboxIdsInOrder: string[], +): number { // Find the last checked checkbox and focus on the one after it - for (let i = values.length - 1; i >= 0; i--) { - if (values[i]) { + for (let i = checkboxIdsInOrder.length - 1; i >= 0; i--) { + const id = checkboxIdsInOrder[i]; + if (id && values[id]) { return i + 1; } } @@ -52,61 +57,75 @@ function RepeatableRenderer(props: RepeatableProps) { // Caching this value internally to detect changes const [hasFocus, setHasFocus] = useState(true); - // Checkbox count is determined by the rehype plugin during rendering. - // We use a ref to capture the count synchronously during the render phase, + // Checkbox IDs are determined by the rehype plugin during rendering. + // We use a ref to capture these synchronously during the render phase, // avoiding the "setState during render" warning. - const checkboxCountRef = useRef(0); + const checkboxIdsRef = useRef([]); // Memoize the rehype plugins array to maintain referential stability + // rehypeRaw must come first to parse HTML comments into the AST const rehypePlugins: PluggableList = useMemo( () => [ + rehypeRaw, [ rehypeCheckboxIndex, - (count: number) => { - checkboxCountRef.current = count; + // CheckboxIdsCallback + (ids: string[]) => { + checkboxIdsRef.current = ids; }, ], ], [], ); + // Function to get checkbox ID at a given index + const getCheckboxId = useCallback((idx: number): string | undefined => { + return checkboxIdsRef.current[idx]; + }, []); + // Initialize focus index on mount or when takesFocus becomes true // Using a ref to track initialization so we don't need values in the dep array - if (takesFocus && !hasInitializedFocus.current) { - hasInitializedFocus.current = true; - const initialIdx = calculateInitialFocusIndex(values); - focusedIdxRef.current = initialIdx; - } else if (!takesFocus && hasInitializedFocus.current) { + // Note: We calculate initial focus in useEffect after first render so checkboxIdsRef is populated + if (!takesFocus && hasInitializedFocus.current) { hasInitializedFocus.current = false; focusedIdxRef.current = null; } // Focus the initial button after mount and notify parent if focus is outside checkboxes useEffect(() => { - if (takesFocus && focusedIdxRef.current !== null) { - debugFocus( - 'useEffect: focusedIdxRef=%d, activeElement=%o', - focusedIdxRef.current, - document.activeElement, - ); - const button = buttonRefs.current.get(focusedIdxRef.current); - if (button) { - debugFocus('useEffect: focusing button %d', focusedIdxRef.current); - button.focus(); - debugFocus('useEffect: after focus, activeElement=%o', document.activeElement); + if (takesFocus) { + // Initialize focus if not yet done + if (!hasInitializedFocus.current) { + hasInitializedFocus.current = true; + const initialIdx = calculateInitialFocusIndex(values, checkboxIdsRef.current); + focusedIdxRef.current = initialIdx; } - // Notify parent when initial focus is beyond the last checkbox (all checked) - if (hasFocusCb) { - const maxIdx = checkboxCountRef.current; - const newHasFocus = focusedIdxRef.current < maxIdx; - if (newHasFocus !== hasFocus) { - hasFocusCb(newHasFocus); - setHasFocus(newHasFocus); + if (focusedIdxRef.current !== null) { + debugFocus( + 'useEffect: focusedIdxRef=%d, activeElement=%o', + focusedIdxRef.current, + document.activeElement, + ); + const button = buttonRefs.current.get(focusedIdxRef.current); + if (button) { + debugFocus('useEffect: focusing button %d', focusedIdxRef.current); + button.focus(); + debugFocus('useEffect: after focus, activeElement=%o', document.activeElement); + } + + // Notify parent when initial focus is beyond the last checkbox (all checked) + if (hasFocusCb) { + const maxIdx = checkboxIdsRef.current.length; + const newHasFocus = focusedIdxRef.current < maxIdx; + if (newHasFocus !== hasFocus) { + hasFocusCb(newHasFocus); + setHasFocus(newHasFocus); + } } } } - }, [takesFocus, hasFocusCb, hasFocus]); + }, [takesFocus, hasFocusCb, hasFocus, values]); // Register button refs from TaskListItem components const registerButton = useCallback((idx: number, element: HTMLElement | null) => { @@ -145,12 +164,12 @@ function RepeatableRenderer(props: RepeatableProps) { }, []); const handleChange = useCallback( - (idx: number) => { + (checkboxId: string, idx: number) => { if (changeValue) { - changeValue(idx); + changeValue(checkboxId); // Advance focus if checking (value was false, now true) // Stay on same checkbox if unchecking (value was true, now false) - const wasChecked = values[idx]; + const wasChecked = values[checkboxId]; const nextFocusIdx = wasChecked ? idx : idx + 1; if (takesFocus) { @@ -158,7 +177,7 @@ function RepeatableRenderer(props: RepeatableProps) { // Notify parent when focus exits checkboxes if (hasFocusCb) { - const maxIdx = checkboxCountRef.current; + const maxIdx = checkboxIdsRef.current.length; const newHasFocus = nextFocusIdx < maxIdx; if (newHasFocus !== hasFocus) { hasFocusCb(newHasFocus); @@ -171,15 +190,27 @@ function RepeatableRenderer(props: RepeatableProps) { [changeValue, values, takesFocus, focusIndex, hasFocusCb, hasFocus], ); + // Wrapper that gets the checkbox ID from the index + const handleChangeByIndex = useCallback( + (idx: number) => { + const checkboxId = checkboxIdsRef.current[idx]; + if (checkboxId) { + handleChange(checkboxId, idx); + } + }, + [handleChange], + ); + // Context value for checkbox components - no focusedIdx, uses registerButton instead const contextValue = useMemo( () => ({ values, - onChange: handleChange, + onChange: changeValue ? handleChangeByIndex : undefined, disabled: !changeValue, registerButton, + getCheckboxId, }), - [values, handleChange, changeValue, registerButton], + [values, handleChangeByIndex, changeValue, registerButton, getCheckboxId], ); const renderedMarkdown = ( diff --git a/src/client/features/Repeatable/migrateValues.test.ts b/src/client/features/Repeatable/migrateValues.test.ts new file mode 100644 index 0000000..0280598 --- /dev/null +++ b/src/client/features/Repeatable/migrateValues.test.ts @@ -0,0 +1,218 @@ +import { describe, expect, it } from 'vitest'; +import { SlugType, type TemplateDoc } from '../../../shared/types'; +import { migrateRepeatableValues } from './migrateValues'; + +function createTemplate( + markdown: string, + values: Array<{ id: string; default: boolean }>, +): TemplateDoc { + return { + _id: 'repeatable:template:test:1', + title: 'Test', + slug: { type: SlugType.Timestamp }, + markdown, + created: Date.now(), + updated: Date.now(), + versioned: Date.now(), + values, + schemaVersion: 2, + }; +} + +describe('migrateRepeatableValues', () => { + it('preserves values for unchanged checkboxes', () => { + const currentValues = { + abc: true, + def: false, + ghi: true, + }; + + const newTemplate = createTemplate( + '- [ ] Task 1 \n- [ ] Task 2 \n- [ ] Task 3 ', + [ + { id: 'abc', default: false }, + { id: 'def', default: false }, + { id: 'ghi', default: false }, + ], + ); + + const result = migrateRepeatableValues(currentValues, newTemplate); + + expect(result).toEqual({ + abc: true, + def: false, + ghi: true, + }); + }); + + it('uses defaults for newly added checkboxes', () => { + const currentValues = { + abc: true, + }; + + const newTemplate = createTemplate( + '- [ ] Task 1 \n- [ ] New Task \n- [ ] Another New ', + [ + { id: 'abc', default: false }, + { id: 'new', default: true }, + { id: 'another', default: false }, + ], + ); + + const result = migrateRepeatableValues(currentValues, newTemplate); + + expect(result).toEqual({ + abc: true, // Preserved + new: true, // From template default + another: false, // From template default + }); + }); + + it('drops values for removed checkboxes', () => { + const currentValues = { + abc: true, + def: false, + ghi: true, + removed: true, + }; + + const newTemplate = createTemplate( + '- [ ] Task 1 \n- [ ] Task 3 ', + [ + { id: 'abc', default: false }, + { id: 'ghi', default: false }, + ], + ); + + const result = migrateRepeatableValues(currentValues, newTemplate); + + expect(result).toEqual({ + abc: true, + ghi: true, + }); + expect(result).not.toHaveProperty('def'); + expect(result).not.toHaveProperty('removed'); + }); + + it('handles complete checkbox replacement', () => { + const currentValues = { + old1: true, + old2: false, + }; + + const newTemplate = createTemplate( + '- [ ] New 1 \n- [ ] New 2 ', + [ + { id: 'new1', default: true }, + { id: 'new2', default: false }, + ], + ); + + const result = migrateRepeatableValues(currentValues, newTemplate); + + expect(result).toEqual({ + new1: true, + new2: false, + }); + }); + + it('handles reordered checkboxes (IDs stay same, order changes)', () => { + const currentValues = { + first: true, + second: false, + third: true, + }; + + // Same IDs but in different order in the template + const newTemplate = createTemplate( + '- [ ] Third \n- [ ] First \n- [ ] Second ', + [ + { id: 'third', default: false }, + { id: 'first', default: false }, + { id: 'second', default: false }, + ], + ); + + const result = migrateRepeatableValues(currentValues, newTemplate); + + // Values should be preserved by ID, not position + expect(result).toEqual({ + third: true, + first: true, + second: false, + }); + }); + + it('handles empty current values', () => { + const currentValues = {}; + + const newTemplate = createTemplate( + '- [ ] Task 1 \n- [ ] Task 2 ', + [ + { id: 'abc', default: true }, + { id: 'def', default: false }, + ], + ); + + const result = migrateRepeatableValues(currentValues, newTemplate); + + expect(result).toEqual({ + abc: true, + def: false, + }); + }); + + it('handles template with no checkboxes', () => { + const currentValues = { + abc: true, + def: false, + }; + + const newTemplate = createTemplate('# No checkboxes anymore', []); + + const result = migrateRepeatableValues(currentValues, newTemplate); + + expect(result).toEqual({}); + }); + + it('defaults to false when checkbox has no template default', () => { + const currentValues = {}; + + // Markdown has checkbox but values array doesn't include its default + const newTemplate = createTemplate('- [ ] Task ', []); + + const result = migrateRepeatableValues(currentValues, newTemplate); + + expect(result).toEqual({ + orphan: false, + }); + }); + + it('handles mixed scenario: some preserved, some new, some removed', () => { + const currentValues = { + kept1: true, + kept2: false, + removed1: true, + removed2: false, + }; + + const newTemplate = createTemplate( + '- [ ] Kept 1 \n- [ ] New 1 \n- [ ] Kept 2 \n- [ ] New 2 ', + [ + { id: 'kept1', default: false }, + { id: 'new1', default: true }, + { id: 'kept2', default: false }, + { id: 'new2', default: false }, + ], + ); + + const result = migrateRepeatableValues(currentValues, newTemplate); + + expect(result).toEqual({ + kept1: true, // Preserved + new1: true, // New with default true + kept2: false, // Preserved + new2: false, // New with default false + }); + }); +}); diff --git a/src/client/features/Repeatable/migrateValues.ts b/src/client/features/Repeatable/migrateValues.ts new file mode 100644 index 0000000..44717df --- /dev/null +++ b/src/client/features/Repeatable/migrateValues.ts @@ -0,0 +1,41 @@ +import type { TemplateDoc } from '../../../shared/types'; +import { parseCheckboxIds } from '../Template/checkboxIds'; + +/** + * Migrate a repeatable's values to a new template version. + * + * When a template is edited, checkboxes may be: + * - Unchanged: Keep the existing value + * - Added: Use the template's default value + * - Removed: Value is dropped (not included in result) + * - Reordered: Values follow their IDs, not positions + * + * @param currentValues - The repeatable's current checkbox values + * @param newTemplate - The new template version to migrate to + * @returns New values Record for the updated template + */ +export function migrateRepeatableValues( + currentValues: Record, + newTemplate: TemplateDoc, +): Record { + const newValues: Record = {}; + + // Get checkbox IDs from new template's markdown + const newCheckboxIds = parseCheckboxIds(newTemplate.markdown); + + for (const { id } of newCheckboxIds) { + if (id in currentValues) { + // Checkbox exists in both - preserve state + newValues[id] = currentValues[id]; + } else { + // New checkbox - use template default + const defaultValue = newTemplate.values.find((v) => v.id === id); + newValues[id] = defaultValue?.default ?? false; + } + } + + // Checkboxes in currentValues but not in newTemplate are dropped + // (they were removed from the template) + + return newValues; +} diff --git a/src/client/features/Repeatable/rehypeCheckboxIndex.test.ts b/src/client/features/Repeatable/rehypeCheckboxIndex.test.ts index eb2adc2..819db90 100644 --- a/src/client/features/Repeatable/rehypeCheckboxIndex.test.ts +++ b/src/client/features/Repeatable/rehypeCheckboxIndex.test.ts @@ -223,4 +223,50 @@ describe('rehypeCheckboxIndex', () => { expect(input.properties?.disabled).toBe(true); expect(input.properties?.className).toBe('my-checkbox'); }); + + it('extracts checkbox ID from comment sibling', () => { + const tree: Root = { + type: 'root', + children: [ + ul([ + { + type: 'element', + tagName: 'li', + properties: {}, + children: [ + checkbox(), + { type: 'text', value: ' ' }, + { type: 'comment', value: ' cb:abc123 ' }, + ], + } as Element, + ]), + ], + }; + + let capturedIds: string[] = []; + rehypeCheckboxIndex((ids) => { + capturedIds = ids; + })(tree); + + const list = tree.children[0] as Element; + const item = list.children[0] as Element; + const input = item.children[0] as Element; + + expect(input.properties?.dataCheckboxId).toBe('abc123'); + expect(capturedIds).toEqual(['abc123']); + }); + + it('passes empty string for checkbox without ID comment', () => { + const tree: Root = { + type: 'root', + children: [checkbox()], + }; + + let capturedIds: string[] = []; + rehypeCheckboxIndex((ids) => { + capturedIds = ids; + })(tree); + + expect(capturedIds).toEqual(['']); + }); }); diff --git a/src/client/features/Repeatable/rehypeCheckboxIndex.ts b/src/client/features/Repeatable/rehypeCheckboxIndex.ts index 17d79f5..d9ffcf4 100644 --- a/src/client/features/Repeatable/rehypeCheckboxIndex.ts +++ b/src/client/features/Repeatable/rehypeCheckboxIndex.ts @@ -1,44 +1,89 @@ -import type { Element, Parent, Root } from 'hast'; +import type { Comment, Element, Parent, Root } from 'hast'; import { visit } from 'unist-util-visit'; -export type CheckboxCountCallback = (count: number) => void; +export type CheckboxInfo = { + index: number; + id: string | undefined; +}; + +export type CheckboxIdsCallback = (checkboxIds: string[]) => void; + +// Regex to extract checkbox ID from HTML comment: +// IDs can be alphanumeric with hyphens and underscores +const CHECKBOX_ID_REGEX = /^\s*cb:([a-zA-Z0-9_-]+)\s*$/; /** - * Rehype plugin that adds data-checkbox-index attribute to task list checkboxes - * and their parent li elements. + * Find checkbox ID from a comment node that follows a checkbox. + */ +function extractCheckboxIdFromComment(comment: Comment): string | undefined { + const match = comment.value.match(CHECKBOX_ID_REGEX); + return match ? match[1] : undefined; +} + +/** + * Rehype plugin that adds data-checkbox-index and data-checkbox-id attributes + * to task list checkboxes and their parent li elements. * * Checkboxes are indexed in document order, including nested checkboxes. - * This allows mapping each checkbox to its position in the values array. - * The index is also added to parent li elements to enable clickable labels. + * The index is used for focus management, while the ID is used for value lookup. + * + * Checkbox IDs are extracted from HTML comments that follow checkboxes: + * - [ ] Task 1 * * Example: A markdown like: - * - [ ] Task 1 - * - [ ] Subtask 1.1 - * - [ ] Task 2 + * - [ ] Task 1 + * - [ ] Subtask 1.1 + * - [ ] Task 2 * * Will produce checkboxes with: - * data-checkbox-index="0" for Task 1 - * data-checkbox-index="1" for Subtask 1.1 - * data-checkbox-index="2" for Task 2 + * data-checkbox-index="0" data-checkbox-id="abc123" for Task 1 + * data-checkbox-index="1" data-checkbox-id="def456" for Subtask 1.1 + * data-checkbox-index="2" data-checkbox-id="ghi789" for Task 2 * - * And the parent li elements will also have the same data-checkbox-index. - * - * @param onCount - Optional callback that receives the total checkbox count after traversal + * @param onCheckboxIds - Optional callback that receives the checkbox IDs after traversal */ -export function rehypeCheckboxIndex(onCount?: CheckboxCountCallback) { +export function rehypeCheckboxIndex(onCheckboxIds?: CheckboxIdsCallback) { return (tree: Root) => { let index = 0; - visit(tree, 'element', (node: Element, _idx, parent: Parent | undefined) => { + const checkboxIds: string[] = []; + + visit(tree, 'element', (node: Element, nodeIdx, parent: Parent | undefined) => { if (node.tagName === 'input' && node.properties?.type === 'checkbox') { node.properties.dataCheckboxIndex = index; - // Also add index to parent li for clickable labels + + // Look for checkbox ID in the parent's children (comment following the checkbox) + let checkboxId: string | undefined; + if (parent && 'children' in parent && typeof nodeIdx === 'number') { + // Search for a comment node after this checkbox in the parent + for (let i = nodeIdx + 1; i < parent.children.length; i++) { + const sibling = parent.children[i]; + if (sibling.type === 'comment') { + checkboxId = extractCheckboxIdFromComment(sibling); + break; + } + // Stop searching if we hit another element (not text/whitespace) + if (sibling.type === 'element') { + break; + } + } + } + + if (checkboxId) { + node.properties.dataCheckboxId = checkboxId; + } + checkboxIds.push(checkboxId ?? ''); + + // Also add attributes to parent li for clickable labels if (parent && 'tagName' in parent && parent.tagName === 'li') { (parent as Element).properties = (parent as Element).properties || {}; (parent as Element).properties.dataCheckboxIndex = index; + if (checkboxId) { + (parent as Element).properties.dataCheckboxId = checkboxId; + } } index++; } }); - onCount?.(index); + onCheckboxIds?.(checkboxIds); }; } diff --git a/src/client/features/Sync/SyncManager.test.tsx b/src/client/features/Sync/SyncManager.test.tsx index b6cfd53..bb5e182 100644 --- a/src/client/features/Sync/SyncManager.test.tsx +++ b/src/client/features/Sync/SyncManager.test.tsx @@ -70,6 +70,8 @@ describe('SyncManager', () => { }); handle.allDocs.mockResolvedValue({ rows: [], + total_rows: 0, + offset: 0, }); handle.bulkDocs.mockResolvedValue([]); }); @@ -117,10 +119,11 @@ describe('SyncManager', () => { _id: 'repeatable:instance:123', _rev: '1-abc', template: 'repeatable:template:test', - values: [], + values: {}, created: Date.now(), updated: Date.now(), slug: 'test', + schemaVersion: 2, }; handle.changes.mockResolvedValue({ @@ -135,6 +138,8 @@ describe('SyncManager', () => { handle.allDocs.mockResolvedValue({ rows: [{ id: localDoc._id, doc: localDoc }], + total_rows: 1, + offset: 0, }); const sentDocs: unknown[] = []; @@ -190,10 +195,11 @@ describe('SyncManager', () => { _id: 'repeatable:instance:456', _rev: '2-xyz', template: 'repeatable:template:test', - values: [true], + values: { 'cb-1': true }, created: Date.now(), updated: Date.now(), slug: 'server-doc', + schemaVersion: 2, }; // Mock local database is empty @@ -203,6 +209,8 @@ describe('SyncManager', () => { handle.allDocs.mockResolvedValue({ rows: [], + total_rows: 0, + offset: 0, }); server.use( @@ -268,6 +276,8 @@ describe('SyncManager', () => { handle.allDocs.mockResolvedValue({ rows: [{ key: deletedDoc._id, error: 'not_found' }], + total_rows: 1, + offset: 0, }); server.use( @@ -350,10 +360,11 @@ describe('SyncManager', () => { _id: 'repeatable:instance:123', _rev: '1-abc', template: 'repeatable:template:test', - values: [], + values: {}, created: Date.now(), updated: Date.now(), slug: 'test', + schemaVersion: 2, }; handle.changes.mockResolvedValue({ @@ -368,6 +379,8 @@ describe('SyncManager', () => { handle.allDocs.mockResolvedValue({ rows: [{ id: localDoc._id, doc: localDoc }], + total_rows: 1, + offset: 0, }); server.use( @@ -438,14 +451,17 @@ describe('SyncManager', () => { _id: 'repeatable:instance:live-update', _rev: '1-live', template: 'repeatable:template:test', - values: [true, false], + values: { 'cb-1': true, 'cb-2': false }, created: Date.now(), updated: Date.now(), slug: 'live-update', + schemaVersion: 2, }; handle.allDocs.mockResolvedValue({ rows: [], + total_rows: 0, + offset: 0, }); server.use( diff --git a/src/client/features/Template/checkboxIds.test.ts b/src/client/features/Template/checkboxIds.test.ts new file mode 100644 index 0000000..71f1523 --- /dev/null +++ b/src/client/features/Template/checkboxIds.test.ts @@ -0,0 +1,257 @@ +import { describe, expect, it } from 'vitest'; +import { + countCheckboxes, + ensureCheckboxIds, + getAllCheckboxIds, + getCheckboxIdAtIndex, + parseCheckboxIds, +} from './checkboxIds'; + +describe('parseCheckboxIds', () => { + it('extracts IDs from HTML comments', () => { + const markdown = `- [ ] Task 1 +- [ ] Task 2 `; + + const result = parseCheckboxIds(markdown); + + expect(result).toHaveLength(2); + expect(result[0]).toEqual({ id: 'abc123', lineIndex: 0 }); + expect(result[1]).toEqual({ id: 'def456', lineIndex: 1 }); + }); + + it('returns IDs in document order', () => { + const markdown = `- [ ] First +Some text +- [ ] Second +- [ ] Third `; + + const result = parseCheckboxIds(markdown); + + expect(result.map((r) => r.id)).toEqual(['first', 'second', 'third']); + expect(result.map((r) => r.lineIndex)).toEqual([0, 2, 3]); + }); + + it('handles checkboxes without IDs (not included)', () => { + const markdown = `- [ ] Has ID +- [ ] No ID +- [ ] Also has ID `; + + const result = parseCheckboxIds(markdown); + + expect(result).toHaveLength(2); + expect(result[0].id).toBe('abc123'); + expect(result[1].id).toBe('def456'); + }); + + it('handles nested checkboxes', () => { + const markdown = `- [ ] Parent + - [ ] Child 1 + - [ ] Child 2 +- [ ] Sibling `; + + const result = parseCheckboxIds(markdown); + + expect(result).toHaveLength(4); + expect(result.map((r) => r.id)).toEqual(['parent', 'child1', 'child2', 'sibling']); + }); + + it('handles * style checkboxes', () => { + const markdown = `* [ ] Task 1 +* [x] Task 2 `; + + const result = parseCheckboxIds(markdown); + + expect(result).toHaveLength(2); + expect(result[0].id).toBe('abc123'); + expect(result[1].id).toBe('def456'); + }); + + it('handles checked checkboxes', () => { + const markdown = `- [x] Checked +- [X] Also checked +- [ ] Unchecked `; + + const result = parseCheckboxIds(markdown); + + expect(result).toHaveLength(3); + }); + + it('handles empty markdown', () => { + const result = parseCheckboxIds(''); + expect(result).toHaveLength(0); + }); + + it('handles markdown with no checkboxes', () => { + const markdown = `# Header +Some text +- Regular list item`; + + const result = parseCheckboxIds(markdown); + expect(result).toHaveLength(0); + }); + + it('handles various ID formats', () => { + const markdown = `- [ ] Task +- [ ] Task +- [ ] Task `; + + const result = parseCheckboxIds(markdown); + + expect(result).toHaveLength(3); + expect(result[0].id).toBe('a1b2c3d4'); + expect(result[1].id).toBe('12345678'); + expect(result[2].id).toBe('abcdefab'); + }); + + it('handles whitespace in comments', () => { + const markdown = `- [ ] Task +- [ ] Task +- [ ] Task `; + + const result = parseCheckboxIds(markdown); + + expect(result).toHaveLength(3); + expect(result[0].id).toBe('nospace'); + expect(result[1].id).toBe('withspace'); + expect(result[2].id).toBe('extraspace'); + }); +}); + +describe('ensureCheckboxIds', () => { + it('adds IDs to checkboxes without them', () => { + const markdown = `- [ ] Task 1 +- [ ] Task 2`; + + const result = ensureCheckboxIds(markdown); + + // Should have added ID comments + expect(result).toMatch(/- \[ \] Task 1 /); + expect(result).toMatch(/- \[ \] Task 2 /); + }); + + it('preserves existing IDs', () => { + const markdown = `- [ ] Task 1 +- [ ] Task 2`; + + const result = ensureCheckboxIds(markdown); + + expect(result).toContain(''); + // Task 2 should have a new ID + const lines = result.split('\n'); + expect(lines[1]).toMatch(//); + }); + + it('generates unique IDs', () => { + const markdown = `- [ ] Task 1 +- [ ] Task 2 +- [ ] Task 3`; + + const result = ensureCheckboxIds(markdown); + const ids = getAllCheckboxIds(result); + + expect(ids).toHaveLength(3); + // All IDs should be unique + expect(new Set(ids).size).toBe(3); + }); + + it('handles mixed checkboxes', () => { + const markdown = `- [ ] Has ID +- [ ] No ID +- [x] Checked no ID`; + + const result = ensureCheckboxIds(markdown); + const ids = getAllCheckboxIds(result); + + expect(ids).toHaveLength(3); + expect(ids[0]).toBe('abc123'); + expect(ids[1]).not.toBe('abc123'); + expect(ids[2]).not.toBe('abc123'); + }); + + it('preserves non-checkbox content', () => { + const markdown = `# Header +- [ ] Task +Some paragraph text. +- Regular list item`; + + const result = ensureCheckboxIds(markdown); + + expect(result).toContain('# Header'); + expect(result).toContain('Some paragraph text.'); + expect(result).toContain('- Regular list item'); + }); + + it('is idempotent', () => { + const markdown = `- [ ] Task 1 +- [ ] Task 2`; + + const firstPass = ensureCheckboxIds(markdown); + const secondPass = ensureCheckboxIds(firstPass); + + // Should be identical after second pass + expect(secondPass).toBe(firstPass); + }); +}); + +describe('getCheckboxIdAtIndex', () => { + it('returns ID at given index', () => { + const markdown = `- [ ] Task 1 +- [ ] Task 2 +- [ ] Task 3 `; + + expect(getCheckboxIdAtIndex(markdown, 0)).toBe('first'); + expect(getCheckboxIdAtIndex(markdown, 1)).toBe('second'); + expect(getCheckboxIdAtIndex(markdown, 2)).toBe('third'); + }); + + it('returns undefined for out of bounds index', () => { + const markdown = `- [ ] Task 1 `; + + expect(getCheckboxIdAtIndex(markdown, 1)).toBeUndefined(); + expect(getCheckboxIdAtIndex(markdown, -1)).toBeUndefined(); + }); + + it('returns undefined for empty markdown', () => { + expect(getCheckboxIdAtIndex('', 0)).toBeUndefined(); + }); +}); + +describe('getAllCheckboxIds', () => { + it('returns all IDs in order', () => { + const markdown = `- [ ] Task 1 +- [ ] Task 2 +- [ ] Task 3 `; + + const ids = getAllCheckboxIds(markdown); + + expect(ids).toEqual(['a', 'b', 'c']); + }); + + it('returns empty array for no checkboxes', () => { + const ids = getAllCheckboxIds('# Just a header'); + expect(ids).toEqual([]); + }); +}); + +describe('countCheckboxes', () => { + it('counts all checkboxes regardless of IDs', () => { + const markdown = `- [ ] Has ID +- [ ] No ID +- [x] Checked`; + + expect(countCheckboxes(markdown)).toBe(3); + }); + + it('returns 0 for no checkboxes', () => { + expect(countCheckboxes('# Header\nSome text')).toBe(0); + }); + + it('counts nested checkboxes', () => { + const markdown = `- [ ] Parent + - [ ] Child 1 + - [ ] Child 2 +- [ ] Sibling`; + + expect(countCheckboxes(markdown)).toBe(4); + }); +}); diff --git a/src/client/features/Template/checkboxIds.ts b/src/client/features/Template/checkboxIds.ts new file mode 100644 index 0000000..5f5a533 --- /dev/null +++ b/src/client/features/Template/checkboxIds.ts @@ -0,0 +1,112 @@ +import { v4 as uuid } from 'uuid'; + +export interface CheckboxInfo { + id: string; + lineIndex: number; +} + +// Regex to match checkbox ID comments: +// IDs can be alphanumeric with hyphens and underscores +const CHECKBOX_ID_REGEX = //; +// Regex to match a checkbox in markdown: - [ ] or - [x] or * [ ] or * [x] +const CHECKBOX_LINE_REGEX = /^(\s*[-*]\s*\[[ xX]\])/; + +/** + * Parse markdown and extract checkbox IDs in document order. + * Returns an array of { id, lineIndex } for checkboxes that have IDs. + * Checkboxes without IDs are not included. + */ +export function parseCheckboxIds(markdown: string): CheckboxInfo[] { + const lines = markdown.split('\n'); + const result: CheckboxInfo[] = []; + + for (let i = 0; i < lines.length; i++) { + const line = lines[i]; + // Check if this line has a checkbox + if (!CHECKBOX_LINE_REGEX.test(line)) { + continue; + } + + // Look for an ID comment on this line + const idMatch = line.match(CHECKBOX_ID_REGEX); + if (idMatch) { + result.push({ + id: idMatch[1], + lineIndex: i, + }); + } + } + + return result; +} + +/** + * Generate a short unique ID for checkboxes. + * Uses first 8 characters of a UUID for brevity while maintaining uniqueness. + */ +export function generateCheckboxId(): string { + // FIXME: make this more robust. Hash of markdown up to and including the checkbox line? + return uuid().substring(0, 8); +} + +/** + * Insert IDs for any checkboxes that don't have them. + * Preserves existing IDs. Returns the modified markdown. + */ +export function ensureCheckboxIds(markdown: string): string { + const lines = markdown.split('\n'); + const result: string[] = []; + + for (const line of lines) { + // Check if this line has a checkbox + if (!CHECKBOX_LINE_REGEX.test(line)) { + result.push(line); + continue; + } + + // Check if it already has an ID + if (CHECKBOX_ID_REGEX.test(line)) { + result.push(line); + continue; + } + + // Add an ID to this checkbox + const id = generateCheckboxId(); + // Insert the ID comment at the end of the line + result.push(`${line} `); + } + + return result.join('\n'); +} + +/** + * Get the checkbox ID at a given document index (0-based). + * This is used when rendering to map positional index to checkbox ID. + */ +export function getCheckboxIdAtIndex(markdown: string, index: number): string | undefined { + const checkboxes = parseCheckboxIds(markdown); + return checkboxes[index]?.id; +} + +/** + * Get all checkbox IDs from markdown in document order. + * This returns just the IDs as an array for simpler iteration. + */ +export function getAllCheckboxIds(markdown: string): string[] { + return parseCheckboxIds(markdown).map((info) => info.id); +} + +/** + * Count the number of checkboxes in the markdown. + * This counts ALL checkboxes, regardless of whether they have IDs. + */ +export function countCheckboxes(markdown: string): number { + const lines = markdown.split('\n'); + let count = 0; + for (const line of lines) { + if (CHECKBOX_LINE_REGEX.test(line)) { + count++; + } + } + return count; +} diff --git a/src/client/features/Template/templateVersions.ts b/src/client/features/Template/templateVersions.ts new file mode 100644 index 0000000..ff68886 --- /dev/null +++ b/src/client/features/Template/templateVersions.ts @@ -0,0 +1,60 @@ +import type { DocId, TemplateDoc } from '../../../shared/types'; + +/** + * Extract the base ID (without version) from a template ID. + * Template ID format: repeatable:template:: + */ +function getTemplateBaseId(templateId: DocId): string { + return templateId.substring(0, templateId.lastIndexOf(':')); +} + +/** + * Extract the version number from a template ID. + */ +function getTemplateVersion(templateId: DocId): number { + const parts = templateId.split(':'); + return Number(parts[3]); +} + +/** + * Get the latest version of a template from the database. + * + * @param templateId - Any version of the template + * @param db - PouchDB database instance + * @returns The latest template version, or null if not found + */ +export async function getLatestTemplateVersion( + templateId: DocId, + db: PouchDB.Database, +): Promise { + const baseId = getTemplateBaseId(templateId); + + // Find all versions of this template + const result = await db.find({ + selector: { + _id: { $gt: baseId, $lte: `${baseId}\uffff` }, + deleted: { $ne: true }, + }, + limit: 1000, + }); + + if (result.docs.length === 0) { + return null; + } + + // Sort by version number (descending) and return the highest + const sorted = result.docs.sort((a, b) => { + const versionA = getTemplateVersion(a._id); + const versionB = getTemplateVersion(b._id); + return versionB - versionA; + }); + + return sorted[0] as TemplateDoc; +} + +/** + * Check if a template ID is the latest version. + */ +export function isLatestVersion(currentTemplateId: DocId, latestTemplate: TemplateDoc): boolean { + return currentTemplateId === latestTemplate._id; +} diff --git a/src/client/pages/Repeatable.test.tsx b/src/client/pages/Repeatable.test.tsx index 03d00b9..0dd1e26 100644 --- a/src/client/pages/Repeatable.test.tsx +++ b/src/client/pages/Repeatable.test.tsx @@ -37,6 +37,8 @@ describe('Repeatable', () => { store.dispatch(setUserAsLoggedIn({ user })); handle = getMockDb(); + // Default mock for find - returns empty docs (no newer version) + handle.find.mockResolvedValue({ docs: [] }); }); function render(children: JSX.Element) { @@ -48,12 +50,15 @@ describe('Repeatable', () => { .mockResolvedValueOnce({ _id: 'repeatable:instance:1234', template: 'repeatable:template:5678', - values: [], + values: {}, + schemaVersion: 2, } satisfies Partial) .mockResolvedValueOnce({ _id: 'repeatable:template:5678', title: 'A Repeatable', markdown: 'Some text', + values: [], + schemaVersion: 2, } satisfies Partial); mockUseLocation.mockReturnValue(undefined); mockUseParams.mockReturnValue({ repeatableId: '1234' }); @@ -72,12 +77,15 @@ describe('Repeatable', () => { slug: { type: SlugType.String, }, + values: [], + schemaVersion: 2, } satisfies Partial) .mockResolvedValueOnce({ _id: 'repeatable:instance:1234', _rev: '42-abc', slug: 'test', - values: [], + values: {}, + schemaVersion: 2, } satisfies Partial); handle.userPut.mockResolvedValue({ _id: '4321' }); mockUseLocation.mockReturnValue({ @@ -114,29 +122,34 @@ describe('Repeatable', () => { beforeEach(() => { handle.get.mockReset(); handle.userPut.mockReset(); + handle.find.mockReset(); mockUseLocation.mockReset(); mockUseParams.mockReset(); repeatable = { _id: 'repeatable:instance:1234', - template: 'repeatable:template:5678', - values: [false], + template: 'repeatable:template:5678:1', + values: { cb1: false }, + schemaVersion: 2, }; template = { - _id: 'repeatable:template:5678', - markdown: 'Some text\n- [ ] Something to change', - values: [false], + _id: 'repeatable:template:5678:1', + markdown: 'Some text\n- [ ] Something to change ', + values: [{ id: 'cb1', default: false }], + schemaVersion: 2, }; handle.get.mockImplementation((docId: string) => { if (docId === 'repeatable:instance:1234') { return Promise.resolve(repeatable); } - if (docId === 'repeatable:template:5678') { + if (docId === 'repeatable:template:5678:1') { return Promise.resolve(template); } return Promise.reject(new Error(`Bad ${docId}`)); }); + // Mock find for getLatestTemplateVersion - return the same template as the latest + handle.find.mockResolvedValue({ docs: [template] }); mockUseLocation.mockReturnValue(undefined); mockUseParams.mockReturnValue({ repeatableId: 'repeatable:instance:1234' }); handle.userPut.mockResolvedValue({ _id: 'repeatable:instance:1234', _rev: '2-abc' }); diff --git a/src/client/pages/Repeatable.tsx b/src/client/pages/Repeatable.tsx index 6c7004b..f81d947 100644 --- a/src/client/pages/Repeatable.tsx +++ b/src/client/pages/Repeatable.tsx @@ -1,14 +1,18 @@ import DeleteIcon from '@mui/icons-material/Delete'; +import EditIcon from '@mui/icons-material/Edit'; +import SystemUpdateAltIcon from '@mui/icons-material/SystemUpdateAlt'; import { Button, ButtonGroup } from '@mui/material'; import qs from 'qs'; import React, { Fragment, useCallback, useEffect, useState } from 'react'; import { useLocation, useNavigate, useParams } from 'react-router-dom'; import { v4 as uuid } from 'uuid'; -import type { RepeatableDoc, TemplateDoc } from '../../shared/types'; +import { CURRENT_SCHEMA_VERSION, type RepeatableDoc, type TemplateDoc } from '../../shared/types'; import db from '../db'; import { usePageContext } from '../features/Page/pageSlice'; +import { migrateRepeatableValues } from '../features/Repeatable/migrateValues'; import RepeatableRenderer from '../features/Repeatable/RepeatableRenderer'; +import { getLatestTemplateVersion, isLatestVersion } from '../features/Template/templateVersions'; import { debugClient } from '../globals'; import { clearRepeatable, clearTemplate, setRepeatable, setTemplate } from '../state/docsSlice'; import { useDispatch, useSelector } from '../store'; @@ -28,6 +32,9 @@ function Repeatable() { const [repeatableHasFocus, setRepeatableHasFocus] = useState(true); + // Track latest template version for update/edit buttons + const [latestTemplate, setLatestTemplate] = useState(null); + const location = useLocation(); const { repeatableId } = useParams(); @@ -50,13 +57,21 @@ function Repeatable() { // if (['url', 'string'].includes(template.slug.type)) { slug = ''; } + + // Create values Record from template defaults + const values: Record = {}; + for (const checkboxValue of template.values) { + values[checkboxValue.id] = checkboxValue.default; + } + const repeatable: RepeatableDoc = { _id: `repeatable:instance:${uuid()}`, template: templateId, - values: template.values, + values, created, updated, slug, + schemaVersion: CURRENT_SCHEMA_VERSION, }; await handle.userPut(repeatable); @@ -71,8 +86,7 @@ function Repeatable() { return navigate('/'); } - // repeatable.values ??= []; - repeatable.values = repeatable.values || []; + repeatable.values ??= {}; debug('post repeatable load, pre template load'); const template: TemplateDoc = await handle.get(repeatable.template); @@ -92,6 +106,17 @@ function Repeatable() { }; }, [dispatch, handle, repeatableId, location, navigate]); + // Check for latest template version when repeatable loads + useEffect(() => { + async function checkLatestVersion() { + if (repeatable?.template) { + const latest = await getLatestTemplateVersion(repeatable.template, handle); + setLatestTemplate(latest); + } + } + checkLatestVersion(); + }, [repeatable?.template, handle]); + // Set page context based on loaded template usePageContext({ title: template?.title, @@ -129,15 +154,36 @@ function Repeatable() { dispatch(setRepeatable(copy)); } + async function handleUpdateTemplate() { + if (!repeatable || !latestTemplate) return; + + const copy = Object.assign({}, repeatable); + copy.values = migrateRepeatableValues(copy.values, latestTemplate); + copy.template = latestTemplate._id; + copy.updated = Date.now(); + + await handle.userPut(copy); + + // Update both repeatable and template in redux + dispatch(setRepeatable(copy)); + dispatch(setTemplate(latestTemplate)); + setLatestTemplate(latestTemplate); // Refresh to same value (now is latest) + } + + function handleEditTemplate() { + if (!repeatable || !template) return; + navigate(`/template/${template._id}/from/${repeatable._id}`); + } + // PERF: stop this from referencing the repeatable or its values // if we can do that (ie just call changes onto redux) this func won't regenerate and force unneccessary rerenders const handleToggle = useCallback( - async (idx: number) => { + async (checkboxId: string) => { const now = Date.now(); const copy = Object.assign({}, repeatable); - copy.values = Array.from(copy.values); + copy.values = { ...copy.values }; - copy.values[idx] = !copy.values[idx]; + copy.values[checkboxId] = !copy.values[checkboxId]; copy.updated = now; @@ -180,6 +226,11 @@ function Repeatable() { } } + // Determine if we should show update or edit button + const isOnLatestVersion = latestTemplate && isLatestVersion(repeatable.template, latestTemplate); + const hasNewerVersion = latestTemplate && !isOnLatestVersion; + const showTemplateButtons = !repeatable.completed; + return ( + {showTemplateButtons && hasNewerVersion && ( + + )} + {showTemplateButtons && isOnLatestVersion && ( + + )} ); diff --git a/src/client/pages/Template.tsx b/src/client/pages/Template.tsx index 602653e..09a3312 100644 --- a/src/client/pages/Template.tsx +++ b/src/client/pages/Template.tsx @@ -11,24 +11,35 @@ import { useNavigate, useParams } from 'react-router-dom'; import { v4 as uuid } from 'uuid'; -import { SlugType, type TemplateDoc } from '../../shared/types'; +import { + CURRENT_SCHEMA_VERSION, + type RepeatableDoc, + SlugType, + type TemplateDoc, +} from '../../shared/types'; import db from '../db'; import { usePageContext } from '../features/Page/pageSlice'; +import { migrateRepeatableValues } from '../features/Repeatable/migrateValues'; import RepeatableRenderer from '../features/Repeatable/RepeatableRenderer'; -import { clearTemplate, setTemplate } from '../state/docsSlice'; +import { ensureCheckboxIds, parseCheckboxIds } from '../features/Template/checkboxIds'; +import { clearRepeatable, clearTemplate, setRepeatable, setTemplate } from '../state/docsSlice'; import { useDispatch, useSelector } from '../store'; function Template() { const template = useSelector((state) => state.docs.template); + const repeatable = useSelector((state) => state.docs.repeatable); const dispatch = useDispatch(); const navigate = useNavigate(); const user = useSelector((state) => state.user.value); const handle = db(user); - const { templateId } = useParams(); + const { templateId, repeatableId } = useParams(); + + // When editing from a repeatable context, we show the repeatable's values in preview + const isInlineEdit = !!repeatableId; useEffect(() => { - async function loadTemplate() { + async function loadData() { if (templateId === 'new') { const now = Date.now(); const template: TemplateDoc = { @@ -42,6 +53,7 @@ function Template() { updated: now, versioned: now, values: [], + schemaVersion: CURRENT_SCHEMA_VERSION, }; await handle.userPut(template); @@ -49,19 +61,27 @@ function Template() { navigate(`/template/${template._id}`, { replace: true }); } else if (templateId) { const template: TemplateDoc = await handle.get(templateId); - dispatch(setTemplate(template)); + + // If we're editing from a repeatable context, load the repeatable too + if (repeatableId) { + const repeatable: RepeatableDoc = await handle.get(repeatableId); + dispatch(setRepeatable(repeatable)); + } } } - loadTemplate(); + loadData(); return () => { dispatch(clearTemplate()); + if (repeatableId) { + dispatch(clearRepeatable()); + } }; - }, [handle, templateId, navigate, dispatch]); + }, [handle, templateId, repeatableId, navigate, dispatch]); usePageContext({ - title: `${template?.title || 'New Template'} | edit`, + title: `${template?.title || 'New Template'} | ${isInlineEdit ? 'inline edit' : 'edit'}`, back: true, under: 'home', }); @@ -98,28 +118,64 @@ function Template() { async function handleSubmit(event: FormEvent | MouseEvent) { event.preventDefault(); - const copy = Object.assign({}, template); - copy.updated = Date.now(); + if (!template) return; - const used = await handle.find({ - selector: { - template: copy._id, - }, - limit: 1000, // PouchDB 9+ requires explicit limit (default is 25) - }); + const templateCopy = Object.assign({}, template); + templateCopy.updated = Date.now(); + + // Ensure all checkboxes have IDs embedded in the markdown + templateCopy.markdown = ensureCheckboxIds(templateCopy.markdown); - if (used.docs.length) { - copy.versioned = copy.updated; + // Parse checkbox IDs and update the values array + const checkboxInfos = parseCheckboxIds(templateCopy.markdown); + const existingValues = new Map(templateCopy.values.map((v) => [v.id, v.default])); + templateCopy.values = checkboxInfos.map((info) => ({ + id: info.id, + default: existingValues.get(info.id) ?? false, + })); - const splitId = copy._id.split(':'); + // Determine if we need to create a new version + let needsNewVersion: boolean; + if (isInlineEdit) { + // When editing from a repeatable context, we always create a new version + needsNewVersion = true; + } else { + // Check if this template is used by any repeatables + const used = await handle.find({ + selector: { + template: templateCopy._id, + }, + limit: 1000, + }); + needsNewVersion = used.docs.length > 0; + } + + if (needsNewVersion) { + templateCopy.versioned = templateCopy.updated; + templateCopy.schemaVersion = CURRENT_SCHEMA_VERSION; + + const splitId = templateCopy._id.split(':'); splitId[3] = String(Number(splitId[3]) + 1); - copy._id = splitId.join(':'); - delete copy._rev; + templateCopy._id = splitId.join(':'); + delete templateCopy._rev; } - await handle.userPut(copy); + await handle.userPut(templateCopy); - navigate(-1); + // If we came from a repeatable, migrate it to the new template version + if (isInlineEdit && repeatable) { + const repeatableCopy = Object.assign({}, repeatable); + repeatableCopy.values = migrateRepeatableValues(repeatableCopy.values, templateCopy); + repeatableCopy.template = templateCopy._id; + repeatableCopy.updated = Date.now(); + + await handle.userPut(repeatableCopy); + + // Navigate back to the repeatable + navigate(`/repeatable/${repeatable._id}`); + } else { + navigate(-1); + } } // TODO: replace this with slice actions so we don't have to do dumb (and slow presumably) copies @@ -212,8 +268,21 @@ function Template() { return null; } + // When editing from a repeatable context, wait for the repeatable to load + if (isInlineEdit && !repeatable) { + return null; + } + + // Use repeatable's values in preview when editing inline, otherwise show unchecked + const previewValues = isInlineEdit && repeatable ? repeatable.values : {}; + return ( -
+ - + diff --git a/src/shared/types.ts b/src/shared/types.ts index 9c727dd..7781aaf 100644 --- a/src/shared/types.ts +++ b/src/shared/types.ts @@ -38,16 +38,48 @@ export type SlugData = | { type: SlugType.Date; value: number } | { type: SlugType.Timestamp; value: number }; +// Schema version 2: Checkbox values use unique IDs +export const CURRENT_SCHEMA_VERSION = 2; + +export interface CheckboxValue { + id: string; + default: boolean; +} + export interface RepeatableDoc extends Doc { template: DocId; slug: string | number | undefined; created: number; updated: number; completed?: number; - values: boolean[]; + values: Record; + schemaVersion: 2; } export interface TemplateDoc extends Doc { + deleted?: boolean; + title: string; + slug: SlugConfig; + markdown: string; + created: number; + updated: number; + versioned: number; + values: CheckboxValue[]; + schemaVersion: 2; +} + +// Legacy types for migration from schema version 1 +export interface LegacyRepeatableDoc extends Doc { + template: DocId; + slug: string | number | undefined; + created: number; + updated: number; + completed?: number; + values: boolean[]; + schemaVersion?: undefined; +} + +export interface LegacyTemplateDoc extends Doc { deleted?: boolean; title: string; slug: SlugConfig; @@ -56,6 +88,7 @@ export interface TemplateDoc extends Doc { updated: number; versioned: number; values: boolean[]; + schemaVersion?: undefined; } export interface ServerToClientEvents { diff --git a/useEffectGone.md b/useEffectGone.md deleted file mode 100644 index a3a7381..0000000 --- a/useEffectGone.md +++ /dev/null @@ -1,202 +0,0 @@ -# useEffect Elimination Plan - -Based on React's guidance from [You Might Not Need an Effect](https://react.dev/learn/you-might-not-need-an-effect), this document identifies useEffect instances that are incorrectly used and should be refactored. - -## Quick Reference: When to Avoid useEffect - -1. **Transforming data for rendering** → Calculate during render or use `useMemo` -2. **Handling user events** → Use event handlers -3. **Resetting state on prop change** → Use `key` prop -4. **Subscribing to external stores** → Use `useSyncExternalStore` -5. **Initializing the app** → Use module-level code - ---- - -## LOW RISK - Safe to Refactor - -These useEffects can be removed with minimal impact and clear alternatives. - -### 1. Page Context Effects (5 instances) - -**Pattern**: Dispatching `setContext()` on mount to set page title/navigation state. - -| File | Lines | Current Code | -|------|-------|--------------| -| [About.tsx](src/client/pages/About.tsx#L68-L76) | 68-76 | `useEffect(() => dispatch(setContext({title: 'About', ...})), [dispatch])` | -| [History.tsx](src/client/pages/History.tsx#L20-L27) | 20-27 | `useEffect(() => dispatch(setContext({title: 'History', ...})), [dispatch])` | -| [Home.tsx](src/client/pages/Home.tsx#L28-L35) | 28-35 | `useEffect(() => dispatch(setContext({title: 'Repeatable Checklists', ...})), [dispatch])` | -| [Template.tsx](src/client/pages/Template.tsx#L62-L70) | 62-70 | `useEffect(() => dispatch(setContext({title: template?.title, ...})))` - **No deps array!** | -| [Repeatable.tsx](src/client/pages/Repeatable.tsx#L37-L100) | 37-100 | Nested inside larger effect (context set at line ~80) | - -**Why Remove**: These are setting state during render based on props/route. This is a classic anti-pattern - dispatching to set derived state that could be computed. - -**Recommended Approach**: -1. Create a custom hook `usePageContext` that calls `dispatch(setContext(...))` during render -2. Or use a layout component pattern where context is set declaratively -3. For Template.tsx specifically: the missing dependency array means it runs on EVERY render - this is a bug - -**Example Refactor**: -```typescript -// Option 1: Custom hook that sets context during render -function usePageContext(context: PageContext) { - const dispatch = useAppDispatch(); - const contextRef = useRef(context); - if (!shallowEqual(contextRef.current, context)) { - contextRef.current = context; - } - useMemo(() => { - dispatch(setContext(contextRef.current)); - }, [dispatch, contextRef.current]); -} - -// Usage -function About() { - usePageContext({ title: 'About', back: true, under: 'about' }); - // ... -} -``` - -**Risk Level**: LOW -- No external systems involved -- No async operations -- Easy to test -- Clear migration path - ---- - -### 2. Debug Filter Management - -**File**: [DebugManager.tsx](src/client/features/Debug/DebugManager.tsx#L12-L24) (lines 12-24) - -**Current Code**: -```typescript -useEffect(() => { - if (debugFilter === undefined) { - dispatch(setDebug(localStorage.getItem(DEBUG_KEY))); - } else if (debugFilter === null) { - debugModule.disable(); - localStorage.removeItem(DEBUG_KEY); - } else { - debugModule.enable(debugFilter); - localStorage.setItem(DEBUG_KEY, debugFilter); - } -}, [debugFilter, dispatch]); -``` - -**Why Remove**: This is initializing state from localStorage and then syncing state changes back. The initialization should happen once at app startup, and the sync should be handled by Redux middleware - not a render effect. - -**Recommended Approach**: -1. Initialize debug filter in Redux initial state (read from localStorage at store creation) -2. Use Redux middleware to sync localStorage on state changes -3. Call `debugModule.enable/disable` from the middleware - -**Risk Level**: LOW -- Debug-only feature -- No user-facing impact if it breaks -- Simple state management pattern - ---- - -## MEDIUM RISK - Refactor with Caution - -These require more careful consideration and testing. - -### 3. Update Manager - Install Update - -**File**: [UpdateManager.tsx](src/client/features/Update/UpdateManager.tsx#L27-L33) (lines 27-33) - -**Current Code**: -```typescript -useEffect(() => { - if (registration && waitingToInstall && userReadyToUpdate) { - registration.waiting?.postMessage({ type: 'SKIP_WAITING' }); - } -}, [registration, userReadyToUpdate, waitingToInstall]); -``` - -**Why Remove**: This is reacting to state changes to trigger an action. The `userReadyToUpdate` flag is set by a user clicking a button - the `postMessage` call should happen directly in that click handler, not as a cascading effect. - -**Recommended Approach**: -- Pass `registration` to the component with the update button -- Call `registration.waiting?.postMessage({ type: 'SKIP_WAITING' })` directly in the click handler -- Remove `userReadyToUpdate` state entirely - -**Risk Level**: MEDIUM -- Critical functionality (PWA updates) -- But relatively simple logic -- Straightforward event-driven refactor - ---- - -### 4. Stale Queue Processing - -**File**: [SyncManager.tsx](src/client/features/Sync/SyncManager.tsx#L211-L228) (lines 211-228) - -**Current Code**: -```typescript -useEffect(() => { - const docs = Object.values(stale); - if (docs.length && socket && socket.connected && state === State.connected) { - socket.emit('docUpdate', docs); - } - dispatch(cleanStale(docs)); -}, [dispatch, socket, stale, state]); -``` - -**Why Remove**: This processes a queue whenever `stale` changes. The stale queue is populated by `userPut` operations - the socket emit should happen as part of that action flow, not as a reactive effect. - -**Recommended Approach**: -- Move to Redux middleware that listens for actions that add to stale queue -- Emit socket events directly from the middleware when docs are added -- Or use a Redux thunk that handles both the local update and socket emit - -**Risk Level**: MEDIUM -- Core sync functionality -- Logic is straightforward -- Need to ensure socket availability in middleware context - ---- - -### 5. Socket Ready Signal - -**File**: [SyncManager.tsx](src/client/features/Sync/SyncManager.tsx#L230-L237) (lines 230-237) - -**Current Code**: -```typescript -useEffect(() => { - if (socket?.connected && state === State.completed) { - socket.emit('ready'); - dispatch(socketConnected()); - } -}, [dispatch, socket, state]); -``` - -**Why Remove**: This is reacting to sync completion to emit a ready signal. The sync completion happens in another useEffect - the ready signal should be emitted directly at the end of that sync logic, not as a cascading effect. - -**Recommended Approach**: -- At the end of the full sync handler (after `dispatch(completeSync())`), emit the ready signal and dispatch `socketConnected()` directly -- This makes the flow explicit rather than reactive - -**Risk Level**: MEDIUM -- Part of sync flow -- Simple conditional logic -- Timing might be tricky - need to ensure socket is available - ---- - -## Summary - -| Risk | Count | Description | -|------|-------|-------------| -| LOW | 6 | 5 page context effects + debug filter management | -| MEDIUM | 3 | Update install trigger, stale queue processing, socket ready signal | - -**Total**: 9 useEffects to refactor - -## Recommended Priority - -1. **First**: Fix the Template.tsx missing dependency array (bug!) -2. **Second**: Refactor page context effects (LOW risk, 5 instances, same pattern) -3. **Third**: Move debug filter to Redux middleware (LOW risk) -4. **Fourth**: Refactor UpdateManager install trigger to event handler (MEDIUM risk) -5. **Fifth**: Refactor SyncManager stale queue and ready signal (MEDIUM risk, related changes) diff --git a/yarn.lock b/yarn.lock index e6c3c98..325acd1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -6687,6 +6687,52 @@ __metadata: languageName: node linkType: hard +"hast-util-from-parse5@npm:^8.0.0": + version: 8.0.3 + resolution: "hast-util-from-parse5@npm:8.0.3" + dependencies: + "@types/hast": "npm:^3.0.0" + "@types/unist": "npm:^3.0.0" + devlop: "npm:^1.0.0" + hastscript: "npm:^9.0.0" + property-information: "npm:^7.0.0" + vfile: "npm:^6.0.0" + vfile-location: "npm:^5.0.0" + web-namespaces: "npm:^2.0.0" + checksum: 40ace6c0ad43c26f721c7499fe408e639cde917b2350c9299635e6326559855896dae3c3ebf7440df54766b96c4276a7823e8f376a2b6a28b37b591f03412545 + languageName: node + linkType: hard + +"hast-util-parse-selector@npm:^4.0.0": + version: 4.0.0 + resolution: "hast-util-parse-selector@npm:4.0.0" + dependencies: + "@types/hast": "npm:^3.0.0" + checksum: 5e98168cb44470dc274aabf1a28317e4feb09b1eaf7a48bbaa8c1de1b43a89cd195cb1284e535698e658e3ec26ad91bc5e52c9563c36feb75abbc68aaf68fb9f + languageName: node + linkType: hard + +"hast-util-raw@npm:^9.0.0": + version: 9.1.0 + resolution: "hast-util-raw@npm:9.1.0" + dependencies: + "@types/hast": "npm:^3.0.0" + "@types/unist": "npm:^3.0.0" + "@ungap/structured-clone": "npm:^1.0.0" + hast-util-from-parse5: "npm:^8.0.0" + hast-util-to-parse5: "npm:^8.0.0" + html-void-elements: "npm:^3.0.0" + mdast-util-to-hast: "npm:^13.0.0" + parse5: "npm:^7.0.0" + unist-util-position: "npm:^5.0.0" + unist-util-visit: "npm:^5.0.0" + vfile: "npm:^6.0.0" + web-namespaces: "npm:^2.0.0" + zwitch: "npm:^2.0.0" + checksum: d0d909d2aedecef6a06f0005cfae410d6475e6e182d768bde30c3af9fcbbe4f9beb0522bdc21d0679cb3c243c0df40385797ed255148d68b3d3f12e82d12aacc + languageName: node + linkType: hard + "hast-util-to-jsx-runtime@npm:^2.0.0": version: 2.3.6 resolution: "hast-util-to-jsx-runtime@npm:2.3.6" @@ -6710,6 +6756,21 @@ __metadata: languageName: node linkType: hard +"hast-util-to-parse5@npm:^8.0.0": + version: 8.0.1 + resolution: "hast-util-to-parse5@npm:8.0.1" + dependencies: + "@types/hast": "npm:^3.0.0" + comma-separated-tokens: "npm:^2.0.0" + devlop: "npm:^1.0.0" + property-information: "npm:^7.0.0" + space-separated-tokens: "npm:^2.0.0" + web-namespaces: "npm:^2.0.0" + zwitch: "npm:^2.0.0" + checksum: 8e8a1817c7ff8906ac66e7201b1b8d19d9e1b705e695a6e71620270d498d982ec1ecc0e227bd517f723e91e7fdfb90ef75f9ae64d14b3b65239a7d5e1194d7dd + languageName: node + linkType: hard + "hast-util-whitespace@npm:^3.0.0": version: 3.0.0 resolution: "hast-util-whitespace@npm:3.0.0" @@ -6719,6 +6780,19 @@ __metadata: languageName: node linkType: hard +"hastscript@npm:^9.0.0": + version: 9.0.1 + resolution: "hastscript@npm:9.0.1" + dependencies: + "@types/hast": "npm:^3.0.0" + comma-separated-tokens: "npm:^2.0.0" + hast-util-parse-selector: "npm:^4.0.0" + property-information: "npm:^7.0.0" + space-separated-tokens: "npm:^2.0.0" + checksum: 18dc8064e5c3a7a2ae862978e626b97a254e1c8a67ee9d0c9f06d373bba155ed805fc5b5ce21b990fb7bc174624889e5e1ce1cade264f1b1d58b48f994bc85ce + languageName: node + linkType: hard + "headers-polyfill@npm:^4.0.2": version: 4.0.3 resolution: "headers-polyfill@npm:4.0.3" @@ -6758,6 +6832,13 @@ __metadata: languageName: node linkType: hard +"html-void-elements@npm:^3.0.0": + version: 3.0.0 + resolution: "html-void-elements@npm:3.0.0" + checksum: a8b9ec5db23b7c8053876dad73a0336183e6162bf6d2677376d8b38d654fdc59ba74fdd12f8812688f7db6fad451210c91b300e472afc0909224e0a44c8610d2 + languageName: node + linkType: hard + "http-cache-semantics@npm:^4.1.1": version: 4.1.1 resolution: "http-cache-semantics@npm:4.1.1" @@ -8581,6 +8662,15 @@ __metadata: languageName: node linkType: hard +"parse5@npm:^7.0.0": + version: 7.3.0 + resolution: "parse5@npm:7.3.0" + dependencies: + entities: "npm:^6.0.0" + checksum: 7fd2e4e247e85241d6f2a464d0085eed599a26d7b0a5233790c49f53473232eb85350e8133344d9b3fd58b89339e7ad7270fe1f89d28abe50674ec97b87f80b5 + languageName: node + linkType: hard + "parse5@npm:^8.0.0": version: 8.0.0 resolution: "parse5@npm:8.0.0" @@ -9460,6 +9550,17 @@ __metadata: languageName: node linkType: hard +"rehype-raw@npm:^7.0.0": + version: 7.0.0 + resolution: "rehype-raw@npm:7.0.0" + dependencies: + "@types/hast": "npm:^3.0.0" + hast-util-raw: "npm:^9.0.0" + vfile: "npm:^6.0.0" + checksum: 1435b4b6640a5bc3abe3b2133885c4dbff5ef2190ef9cfe09d6a63f74dd7d7ffd0cede70603278560ccf1acbfb9da9faae4b68065a28bc5aa88ad18e40f32d52 + languageName: node + linkType: hard + "remark-gfm@npm:^4.0.1": version: 4.0.1 resolution: "remark-gfm@npm:4.0.1" @@ -9797,6 +9898,7 @@ __metadata: react-redux: "npm:9.2.0" react-router-dom: "npm:^7.12.0" react-test-renderer: "npm:19.2.3" + rehype-raw: "npm:^7.0.0" remark-gfm: "npm:^4.0.1" socket.io: "npm:4.8.3" socket.io-client: "npm:4.8.3" @@ -10791,6 +10893,16 @@ __metadata: languageName: node linkType: hard +"vfile-location@npm:^5.0.0": + version: 5.0.3 + resolution: "vfile-location@npm:5.0.3" + dependencies: + "@types/unist": "npm:^3.0.0" + vfile: "npm:^6.0.0" + checksum: 1711f67802a5bc175ea69750d59863343ed43d1b1bb25c0a9063e4c70595e673e53e2ed5cdbb6dcdc370059b31605144d95e8c061b9361bcc2b036b8f63a4966 + languageName: node + linkType: hard + "vfile-message@npm:^4.0.0": version: 4.0.3 resolution: "vfile-message@npm:4.0.3" @@ -10956,6 +11068,13 @@ __metadata: languageName: node linkType: hard +"web-namespaces@npm:^2.0.0": + version: 2.0.1 + resolution: "web-namespaces@npm:2.0.1" + checksum: df245f466ad83bd5cd80bfffc1674c7f64b7b84d1de0e4d2c0934fb0782e0a599164e7197a4bce310ee3342fd61817b8047ff04f076a1ce12dd470584142a4bd + languageName: node + linkType: hard + "webidl-conversions@npm:^3.0.0": version: 3.0.1 resolution: "webidl-conversions@npm:3.0.1"