diff --git a/formulus-formplayer/src/AdateQuestionRenderer.tsx b/formulus-formplayer/src/AdateQuestionRenderer.tsx new file mode 100644 index 000000000..4ff7e3ffb --- /dev/null +++ b/formulus-formplayer/src/AdateQuestionRenderer.tsx @@ -0,0 +1,314 @@ +import React, { useState, useCallback, useEffect } from 'react'; +import { withJsonFormsControlProps } from '@jsonforms/react'; +import { ControlProps, rankWith, schemaTypeIs, and, schemaMatches } from '@jsonforms/core'; +import { TextField, Box, Typography, Alert, Button } from '@mui/material'; +import { CalendarToday } from '@mui/icons-material'; +import QuestionShell from './QuestionShell'; +import { + adateToStorageFormat, + storageFormatToAdate, + displayAdate, + todayAdate, + yesterdayAdate, +} from './adateUtils'; + +// Tester function - determines when this renderer should be used +export const adateQuestionTester = rankWith( + 5, // Priority (higher = more specific) + and( + schemaTypeIs('string'), // Expects string data type + schemaMatches((schema) => schema.format === 'adate'), // Matches format + ), +); + +const AdateQuestionRenderer: React.FC = ({ + data, + handleChange, + path, + errors, + schema, + uischema, + enabled = true, + visible = true, +}) => { + // State for date components + const [day, setDay] = useState(''); + const [month, setMonth] = useState(''); + const [year, setYear] = useState(''); + const [dayUnknown, setDayUnknown] = useState(false); + const [monthUnknown, setMonthUnknown] = useState(false); + const [yearUnknown, setYearUnknown] = useState(false); + + // Initialize from data + useEffect(() => { + if (data && typeof data === 'string') { + // Convert storage format to adate format for editing + const adateFormat = storageFormatToAdate(data); + if (adateFormat) { + const upperAdate = adateFormat.toUpperCase(); + const dayMatch = upperAdate.match(/D:(\d+|NS)/); + const monthMatch = upperAdate.match(/M:(\d+|NS)/); + const yearMatch = upperAdate.match(/Y:(\d+|NS)/); + + if (dayMatch) { + setDayUnknown(dayMatch[1] === 'NS'); + setDay(dayMatch[1] === 'NS' ? '' : dayMatch[1]); + } + if (monthMatch) { + setMonthUnknown(monthMatch[1] === 'NS'); + setMonth(monthMatch[1] === 'NS' ? '' : monthMatch[1]); + } + if (yearMatch) { + setYearUnknown(yearMatch[1] === 'NS'); + setYear(yearMatch[1] === 'NS' ? '' : yearMatch[1]); + } + } + } else { + // Initialize empty + setDay(''); + setMonth(''); + setYear(''); + setDayUnknown(false); + setMonthUnknown(false); + setYearUnknown(false); + } + }, [data]); + + // Update form data when components change + const updateFormData = useCallback(() => { + const dayValue = dayUnknown ? 'NS' : day; + const monthValue = monthUnknown ? 'NS' : month; + const yearValue = yearUnknown ? 'NS' : year; + + // Build adate string + const adateString = `D:${dayValue},M:${monthValue},Y:${yearValue}`; + + // Convert to storage format and save + const storageFormat = adateToStorageFormat(adateString); + if (storageFormat) { + handleChange(path, storageFormat); + } else { + handleChange(path, ''); + } + }, [day, month, year, dayUnknown, monthUnknown, yearUnknown, handleChange, path]); + + // Handle day change + const handleDayChange = useCallback( + (event: React.ChangeEvent) => { + const value = event.target.value; + if ( + value === '' || + (/^\d+$/.test(value) && parseInt(value, 10) >= 1 && parseInt(value, 10) <= 31) + ) { + setDay(value); + updateFormData(); + } + }, + [updateFormData], + ); + + // Handle month change + const handleMonthChange = useCallback( + (event: React.ChangeEvent) => { + const value = event.target.value; + if ( + value === '' || + (/^\d+$/.test(value) && parseInt(value, 10) >= 1 && parseInt(value, 10) <= 12) + ) { + setMonth(value); + updateFormData(); + } + }, + [updateFormData], + ); + + // Handle year change + const handleYearChange = useCallback( + (event: React.ChangeEvent) => { + const value = event.target.value; + if (value === '' || /^\d{4}$/.test(value)) { + setYear(value); + updateFormData(); + } + }, + [updateFormData], + ); + + // Handle quick date buttons + const handleToday = useCallback(() => { + const today = todayAdate(); + const upperAdate = today.toUpperCase(); + const dayMatch = upperAdate.match(/D:(\d+)/); + const monthMatch = upperAdate.match(/M:(\d+)/); + const yearMatch = upperAdate.match(/Y:(\d+)/); + + if (dayMatch) setDay(dayMatch[1]); + if (monthMatch) setMonth(monthMatch[1]); + if (yearMatch) setYear(yearMatch[1]); + setDayUnknown(false); + setMonthUnknown(false); + setYearUnknown(false); + updateFormData(); + }, [updateFormData]); + + const handleYesterday = useCallback(() => { + const yesterday = yesterdayAdate(); + const upperAdate = yesterday.toUpperCase(); + const dayMatch = upperAdate.match(/D:(\d+)/); + const monthMatch = upperAdate.match(/M:(\d+)/); + const yearMatch = upperAdate.match(/Y:(\d+)/); + + if (dayMatch) setDay(dayMatch[1]); + if (monthMatch) setMonth(monthMatch[1]); + if (yearMatch) setYear(yearMatch[1]); + setDayUnknown(false); + setMonthUnknown(false); + setYearUnknown(false); + updateFormData(); + }, [updateFormData]); + + // Don't render if not visible + if (!visible) { + return null; + } + + const hasError = errors && (Array.isArray(errors) ? errors.length > 0 : errors.length > 0); + const displayValue = data ? displayAdate(data) : ''; + const errorMessage = hasError + ? Array.isArray(errors) + ? errors.join(', ') + : String(errors) + : undefined; + + return ( + + + {/* Quick date buttons */} + + + + + + {/* Date input fields */} + + {/* Day */} + + + + { + setDayUnknown(e.target.checked); + if (e.target.checked) setDay(''); + updateFormData(); + }} + disabled={!enabled} + style={{ cursor: enabled ? 'pointer' : 'not-allowed' }} + /> + Unknown + + + + {/* Month */} + + + + { + setMonthUnknown(e.target.checked); + if (e.target.checked) setMonth(''); + updateFormData(); + }} + disabled={!enabled} + style={{ cursor: enabled ? 'pointer' : 'not-allowed' }} + /> + Unknown + + + + {/* Year */} + + + + { + setYearUnknown(e.target.checked); + if (e.target.checked) setYear(''); + updateFormData(); + }} + disabled={!enabled} + style={{ cursor: enabled ? 'pointer' : 'not-allowed' }} + /> + Unknown + + + + + {/* Display current value */} + {displayValue && displayValue !== 'n/a' && ( + + + Current value: {displayValue} + + + )} + + {/* Validation errors */} + {hasError && ( + + {Array.isArray(errors) ? errors.join(', ') : String(errors)} + + )} + + + ); +}; + +export default withJsonFormsControlProps(AdateQuestionRenderer); diff --git a/formulus-formplayer/src/App.tsx b/formulus-formplayer/src/App.tsx index 4d1282b2b..74998bd18 100644 --- a/formulus-formplayer/src/App.tsx +++ b/formulus-formplayer/src/App.tsx @@ -26,6 +26,7 @@ import AudioQuestionRenderer, { audioQuestionTester } from './AudioQuestionRende import GPSQuestionRenderer, { gpsQuestionTester } from './GPSQuestionRenderer'; import VideoQuestionRenderer, { videoQuestionTester } from './VideoQuestionRenderer'; import HtmlLabelRenderer, { htmlLabelTester } from './HtmlLabelRenderer'; +import AdateQuestionRenderer, { adateQuestionTester } from './AdateQuestionRenderer'; import { shellMaterialRenderers } from './material-wrappers'; import ErrorBoundary from './ErrorBoundary'; @@ -174,6 +175,7 @@ export const customRenderers = [ { tester: gpsQuestionTester, renderer: GPSQuestionRenderer }, { tester: videoQuestionTester, renderer: VideoQuestionRenderer }, { tester: htmlLabelTester, renderer: HtmlLabelRenderer }, + { tester: adateQuestionTester, renderer: AdateQuestionRenderer }, ]; function App() { @@ -551,6 +553,15 @@ function App() { ajv.addFormat('audio', () => true); // Accept any value for audio format ajv.addFormat('gps', () => true); // Accept any value for GPS format ajv.addFormat('video', () => true); // Accept any value for video format + ajv.addFormat('adate', (data: any) => { + // Allow null, undefined, or empty string (for optional fields) + if (data === null || data === undefined || data === '') { + return true; + } + // Validate YYYY-MM-DD format (may contain ?? for unknown parts) + const dateRegex = /^(\d{4}|\?\?\?\?)-(\d{2}|\?\?)-(\d{2}|\?\?)$/; + return typeof data === 'string' && dateRegex.test(data); + }); // Show draft selector if we have pending form init and available drafts if (showDraftSelector && pendingFormInit) { diff --git a/formulus-formplayer/src/FinalizeRenderer.tsx b/formulus-formplayer/src/FinalizeRenderer.tsx index 519a31d28..4b1fb6619 100644 --- a/formulus-formplayer/src/FinalizeRenderer.tsx +++ b/formulus-formplayer/src/FinalizeRenderer.tsx @@ -16,6 +16,7 @@ import { ControlProps } from '@jsonforms/core'; import { ErrorObject } from 'ajv'; import { useFormContext } from './App'; import EditIcon from '@mui/icons-material/Edit'; +import { displayAdate } from './adateUtils'; interface SummaryItem { label: string; @@ -101,6 +102,8 @@ const FinalizeRenderer = ({ return new Date(value).toLocaleString(); case 'time': return value; + case 'adate': + return displayAdate(value); } } diff --git a/formulus-formplayer/src/adateUtils.ts b/formulus-formplayer/src/adateUtils.ts new file mode 100644 index 000000000..dfbad492d --- /dev/null +++ b/formulus-formplayer/src/adateUtils.ts @@ -0,0 +1,171 @@ +/** + * Utility functions for handling approximate dates (adate) + * + * Adate format: D:DD,M:MM,Y:YYYY (e.g., "D:15,M:06,Y:2024") + * Storage format: YYYY-MM-DD with uncertainty markers (e.g., "2024-06-15", "2024-06-??") + * + * The storage format is year-first to ensure SQL sortability. + */ + +const NA = 'NS'; // Value to use for N/A (Not Specified) + +/** + * Converts adate format (D:DD,M:MM,Y:YYYY) to year-first storage format (YYYY-MM-DD) + * Handles uncertainty markers (NS) by converting them to ?? + * + * @param adate - Adate string in format "D:DD,M:MM,Y:YYYY" or already in YYYY-MM-DD format + * @returns Year-first format string (YYYY-MM-DD) with ?? for unknown parts, or null if invalid + */ +export function adateToStorageFormat(adate: string | null | undefined): string | null { + if (!adate || typeof adate !== 'string') { + return null; + } + + // If already in YYYY-MM-DD format, return as-is (may contain ??) + if ( + /^\d{4}-\d{2}-\d{2}$/.test(adate) || + /^\d{4}-\?\?-\d{2}$/.test(adate) || + /^\d{4}-\d{2}-\?\?$/.test(adate) || + /^\d{4}-\?\?-\?\?$/.test(adate) || + /^\?\?\?\?-\d{2}-\d{2}$/.test(adate) + ) { + return adate; + } + + // Parse D:DD,M:MM,Y:YYYY format + const upperAdate = adate.toUpperCase(); + + // Extract day, month, year + const dayMatch = upperAdate.match(/D:(\d+|NS)/); + const monthMatch = upperAdate.match(/M:(\d+|NS)/); + const yearMatch = upperAdate.match(/Y:(\d+|NS)/); + + if (!yearMatch) { + console.warn('Unable to parse year from adate:', adate); + return null; + } + + const day = dayMatch && dayMatch[1] !== NA ? dayMatch[1].padStart(2, '0') : '??'; + const month = monthMatch && monthMatch[1] !== NA ? monthMatch[1].padStart(2, '0') : '??'; + const year = yearMatch[1] !== NA ? yearMatch[1] : '????'; + + // Validate year is 4 digits if not unknown + if (year !== '????' && year.length !== 4) { + console.warn('Invalid year format in adate:', adate); + return null; + } + + return `${year}-${month}-${day}`; +} + +/** + * Converts year-first storage format (YYYY-MM-DD) back to adate format (D:DD,M:MM,Y:YYYY) + * + * @param storageFormat - Year-first format string (YYYY-MM-DD) with ?? for unknown parts + * @returns Adate string in format "D:DD,M:MM,Y:YYYY" with NS for unknown parts + */ +export function storageFormatToAdate(storageFormat: string | null | undefined): string | null { + if (!storageFormat || typeof storageFormat !== 'string') { + return null; + } + + // Parse YYYY-MM-DD format (may contain ??) + const parts = storageFormat.split('-'); + if (parts.length !== 3) { + console.warn('Invalid storage format:', storageFormat); + return null; + } + + const [year, month, day] = parts; + + const dayValue = day === '??' ? NA : parseInt(day, 10).toString(); + const monthValue = month === '??' ? NA : parseInt(month, 10).toString(); + const yearValue = year === '????' ? NA : year; + + return `D:${dayValue},M:${monthValue},Y:${yearValue}`; +} + +/** + * Checks if an adate has uncertainty (unknown parts) + */ +export function hasUncertainty(adate: string | null | undefined): boolean { + if (!adate) return true; + const upperAdate = typeof adate === 'string' ? adate.toUpperCase() : ''; + return upperAdate.indexOf(NA) > -1 || upperAdate.indexOf('??') > -1; +} + +/** + * Checks if year is unknown in an adate + */ +export function yearUnknown(adate: string | null | undefined): boolean { + if (!adate) return true; + const upperAdate = typeof adate === 'string' ? adate.toUpperCase() : ''; + return upperAdate.indexOf(`Y:${NA}`) > -1 || upperAdate.indexOf('????') > -1; +} + +/** + * Checks if month is unknown in an adate + */ +export function monthUnknown(adate: string | null | undefined): boolean { + if (!adate) return true; + const upperAdate = typeof adate === 'string' ? adate.toUpperCase() : ''; + return upperAdate.indexOf(`M:${NA}`) > -1 || upperAdate.split('-')[1] === '??'; +} + +/** + * Checks if day is unknown in an adate + */ +export function dayUnknown(adate: string | null | undefined): boolean { + if (!adate) return true; + const upperAdate = typeof adate === 'string' ? adate.toUpperCase() : ''; + return upperAdate.indexOf(`D:${NA}`) > -1 || upperAdate.split('-')[2] === '??'; +} + +/** + * Formats an adate for display + * Shows ?? for unknown parts + */ +export function displayAdate(adate: string | null | undefined): string { + if (!adate) return 'n/a'; + + // Convert to storage format first if needed + const storageFormat = adateToStorageFormat(adate); + if (!storageFormat) return 'n/a'; + + const [year, month, day] = storageFormat.split('-'); + + // Format based on what's known + if (day === '??' && month === '??') { + return `??/??/${year}`; + } else if (day === '??') { + return `??/${month}/${year}`; + } else if (month === '??') { + return `${day}/??/${year}`; + } else { + return `${day}/${month}/${year}`; + } +} + +/** + * Gets today's date in adate format + */ +export function todayAdate(): string { + const today = new Date(); + const day = today.getDate(); + const month = today.getMonth() + 1; + const year = today.getFullYear(); + return `D:${day},M:${month},Y:${year}`; +} + +/** + * Gets yesterday's date in adate format + */ +export function yesterdayAdate(): string { + const today = new Date(); + const yesterday = new Date(today); + yesterday.setDate(today.getDate() - 1); + const day = yesterday.getDate(); + const month = yesterday.getMonth() + 1; + const year = yesterday.getFullYear(); + return `D:${day},M:${month},Y:${year}`; +} diff --git a/formulus/formplayer_question_types.md b/formulus/formplayer_question_types.md index 5950ac969..c25c826b0 100644 --- a/formulus/formplayer_question_types.md +++ b/formulus/formplayer_question_types.md @@ -524,6 +524,133 @@ The GPS question type stores location data as a JSON string with the following s } ``` +## Approximate Date (adate) + +The approximate date question type allows users to enter dates with support for unknown day, month, or year components. Dates are stored in YYYY-MM-DD format (year-first) to ensure they are sortable in SQL queries. + +### Schema Definition + +```json +{ + "approximateDate": { + "type": "string", + "format": "adate", + "title": "Approximate Date", + "description": "Enter an approximate date (day, month, or year can be unknown)" + } +} +``` + +### UI Schema + +```json +{ + "type": "Control", + "scope": "#/properties/approximateDate" +} +``` + +### Features + +- **Year-First Storage**: Stored in YYYY-MM-DD format (ISO 8601 date format, year-first) +- **SQL Sortability**: Automatically sortable in SQL queries due to year-first format +- **Uncertainty Support**: Supports uncertainty markers (??) for unknown day, month, or year +- **Quick Date Buttons**: Today and Yesterday buttons for quick date entry +- **Individual Controls**: Separate checkboxes to mark day/month/year as unknown +- **Display Format**: Dates are displayed in DD/MM/YYYY format with ?? for unknown parts + +### Data Structure + +The adate field stores a string in YYYY-MM-DD format with optional uncertainty markers: + +- **Complete date**: `"2024-06-15"` → June 15, 2024 +- **Day unknown**: `"2024-06-??"` → June ??, 2024 +- **Month unknown**: `"2024-??-15"` → ?? 15, 2024 +- **Day & month unknown**: `"2024-??-??"` → ?? ??, 2024 + +### Display Format + +Dates are displayed in DD/MM/YYYY format with ?? for unknown parts: + +- `"2024-06-15"` → `"15/06/2024"` +- `"2024-06-??"` → `"??/06/2024"` +- `"2024-??-15"` → `"15/??/2024"` +- `"2024-??-??"` → `"??/??/2024"` + +### SQL Sortability + +The year-first format ensures that dates sort correctly in SQL queries: + +```sql +ORDER BY approximate_date ASC +-- Results: "2024-??-??", "2024-06-??", "2024-06-15", "2024-12-31", "2025-01-01" +``` + +### Internal Format Conversion + +The adate system uses two formats: + +1. **Storage Format** (YYYY-MM-DD): Used in the database and for sorting + + - Example: `"2024-06-15"`, `"2024-06-??"` + +2. **Adate Format** (D:DD,M:MM,Y:YYYY): Legacy format for compatibility + - Example: `"D:15,M:06,Y:2024"`, `"D:NS,M:06,Y:2024"` + +The system automatically converts between these formats as needed. + +### Usage Examples + +#### Basic Adate Field + +```json +{ + "schema": { + "type": "object", + "properties": { + "eventDate": { + "type": "string", + "format": "adate", + "title": "Event Date" + } + } + } +} +``` + +#### Adate Field with Description + +```json +{ + "schema": { + "type": "object", + "properties": { + "birthDate": { + "type": "string", + "format": "adate", + "title": "Birth Date", + "description": "Enter your birth date. You can mark day or month as unknown if you're not sure." + } + } + } +} +``` + +### Implementation Details + +The adate question type is implemented using: + +1. **AdateQuestionRenderer**: React component for date input with uncertainty options +2. **adateUtils**: Utility functions for format conversion and date operations +3. **Backend Recognition**: PostgreSQL data export service recognizes adate format patterns +4. **Format Validation**: AJV format validator ensures proper YYYY-MM-DD format + +### Dependencies + +- **React**: Uses Material-UI components for the date input interface +- **No Native Dependencies**: Unlike other question types, adate doesn't require native device functionality +- **Backend Support**: Requires PostgreSQL with pattern matching support for format detection + ## Implementation Guide When adding new question types to the Formplayer, follow this established pattern: diff --git a/synkronus/pkg/dataexport/postgres.go b/synkronus/pkg/dataexport/postgres.go index dcf9a8dbe..d850953eb 100644 --- a/synkronus/pkg/dataexport/postgres.go +++ b/synkronus/pkg/dataexport/postgres.go @@ -83,7 +83,22 @@ func (p *postgresDB) GetFormTypeSchema(ctx context.Context, formType string) (*F CASE WHEN type_count > 1 THEN 'text' WHEN types_found = 'number' THEN 'numeric' - WHEN types_found = 'string' THEN 'text' + WHEN types_found = 'string' THEN + CASE + -- Detect adate format: YYYY-MM-DD or YYYY-??-?? pattern (year-first, sortable) + WHEN EXISTS ( + SELECT 1 FROM public.observations o + WHERE o.form_type = $1 + AND o.deleted = false + AND o.data ? key + AND o.data->>key IS NOT NULL + AND (o.data->>key ~ '^\d{4}-\d{2}-\d{2}$' OR + o.data->>key ~ '^\d{4}-\?\?-\d{2}$' OR + o.data->>key ~ '^\d{4}-\d{2}-\?\?$' OR + o.data->>key ~ '^\d{4}-\?\?-\?\?$') + ) THEN 'adate' -- Mark as adate type for proper handling + ELSE 'text' + END WHEN types_found = 'boolean' THEN 'boolean' ELSE 'text' END AS sql_type @@ -134,6 +149,10 @@ func (p *postgresDB) GetObservationsForFormType(ctx context.Context, formType st selectParts = append(selectParts, fmt.Sprintf("(data ->> '%s')::numeric AS data_%s", col.Key, col.Key)) case "boolean": selectParts = append(selectParts, fmt.Sprintf("(data ->> '%s')::boolean AS data_%s", col.Key, col.Key)) + case "adate": + // Adate strings in YYYY-MM-DD format are sortable as text + // The year-first format ensures proper chronological ordering + selectParts = append(selectParts, fmt.Sprintf("(data ->> '%s')::text AS data_%s", col.Key, col.Key)) default: selectParts = append(selectParts, fmt.Sprintf("(data ->> '%s')::text AS data_%s", col.Key, col.Key)) }