From debc6559c9104bbe17f9d032fbf79a0b2350ee2d Mon Sep 17 00:00:00 2001 From: eewing Date: Tue, 17 Feb 2026 10:59:31 -0600 Subject: [PATCH] Add sentiment feature to employee reports - Introduced a new `Sentiment` field in the `EmployeeReportRecord` interface to capture report sentiment as 'Positive', 'Negative', or 'Neutral'. - Updated the report creation and editing forms in Svelte to include a sentiment selection dropdown. - Enhanced API endpoints to handle sentiment data during report creation and updates. - Adjusted the display logic to show sentiment information in the report details. --- src/lib/components/ui/select/index.ts | 37 +++++++++ .../ui/select/select-content.svelte | 45 +++++++++++ .../ui/select/select-group-heading.svelte | 21 +++++ .../components/ui/select/select-group.svelte | 7 ++ .../components/ui/select/select-item.svelte | 38 +++++++++ .../components/ui/select/select-label.svelte | 20 +++++ .../components/ui/select/select-portal.svelte | 7 ++ .../select/select-scroll-down-button.svelte | 20 +++++ .../ui/select/select-scroll-up-button.svelte | 20 +++++ .../ui/select/select-separator.svelte | 18 +++++ .../ui/select/select-trigger.svelte | 29 +++++++ src/lib/components/ui/select/select.svelte | 11 +++ src/lib/components/ui/separator/index.ts | 7 ++ .../components/ui/separator/separator.svelte | 21 +++++ src/lib/date-utils.ts | 48 +++++++++++ src/routes/[id]/+page.server.ts | 3 + src/routes/[id]/+page.svelte | 79 ++++++++++++++++++- .../api/employees/[id]/reports/+server.ts | 11 ++- .../[id]/reports/[reportId]/+server.ts | 4 + 19 files changed, 442 insertions(+), 4 deletions(-) create mode 100644 src/lib/components/ui/select/index.ts create mode 100644 src/lib/components/ui/select/select-content.svelte create mode 100644 src/lib/components/ui/select/select-group-heading.svelte create mode 100644 src/lib/components/ui/select/select-group.svelte create mode 100644 src/lib/components/ui/select/select-item.svelte create mode 100644 src/lib/components/ui/select/select-label.svelte create mode 100644 src/lib/components/ui/select/select-portal.svelte create mode 100644 src/lib/components/ui/select/select-scroll-down-button.svelte create mode 100644 src/lib/components/ui/select/select-scroll-up-button.svelte create mode 100644 src/lib/components/ui/select/select-separator.svelte create mode 100644 src/lib/components/ui/select/select-trigger.svelte create mode 100644 src/lib/components/ui/select/select.svelte create mode 100644 src/lib/components/ui/separator/index.ts create mode 100644 src/lib/components/ui/separator/separator.svelte create mode 100644 src/lib/date-utils.ts diff --git a/src/lib/components/ui/select/index.ts b/src/lib/components/ui/select/index.ts new file mode 100644 index 0000000..4dec358 --- /dev/null +++ b/src/lib/components/ui/select/index.ts @@ -0,0 +1,37 @@ +import Root from "./select.svelte"; +import Group from "./select-group.svelte"; +import Label from "./select-label.svelte"; +import Item from "./select-item.svelte"; +import Content from "./select-content.svelte"; +import Trigger from "./select-trigger.svelte"; +import Separator from "./select-separator.svelte"; +import ScrollDownButton from "./select-scroll-down-button.svelte"; +import ScrollUpButton from "./select-scroll-up-button.svelte"; +import GroupHeading from "./select-group-heading.svelte"; +import Portal from "./select-portal.svelte"; + +export { + Root, + Group, + Label, + Item, + Content, + Trigger, + Separator, + ScrollDownButton, + ScrollUpButton, + GroupHeading, + Portal, + // + Root as Select, + Group as SelectGroup, + Label as SelectLabel, + Item as SelectItem, + Content as SelectContent, + Trigger as SelectTrigger, + Separator as SelectSeparator, + ScrollDownButton as SelectScrollDownButton, + ScrollUpButton as SelectScrollUpButton, + GroupHeading as SelectGroupHeading, + Portal as SelectPortal, +}; diff --git a/src/lib/components/ui/select/select-content.svelte b/src/lib/components/ui/select/select-content.svelte new file mode 100644 index 0000000..4b9ca43 --- /dev/null +++ b/src/lib/components/ui/select/select-content.svelte @@ -0,0 +1,45 @@ + + + + + + + {@render children?.()} + + + + diff --git a/src/lib/components/ui/select/select-group-heading.svelte b/src/lib/components/ui/select/select-group-heading.svelte new file mode 100644 index 0000000..1fab5f0 --- /dev/null +++ b/src/lib/components/ui/select/select-group-heading.svelte @@ -0,0 +1,21 @@ + + + + {@render children?.()} + diff --git a/src/lib/components/ui/select/select-group.svelte b/src/lib/components/ui/select/select-group.svelte new file mode 100644 index 0000000..a1f43bf --- /dev/null +++ b/src/lib/components/ui/select/select-group.svelte @@ -0,0 +1,7 @@ + + + diff --git a/src/lib/components/ui/select/select-item.svelte b/src/lib/components/ui/select/select-item.svelte new file mode 100644 index 0000000..b85eef6 --- /dev/null +++ b/src/lib/components/ui/select/select-item.svelte @@ -0,0 +1,38 @@ + + + + {#snippet children({ selected, highlighted })} + + {#if selected} + + {/if} + + {#if childrenProp} + {@render childrenProp({ selected, highlighted })} + {:else} + {label || value} + {/if} + {/snippet} + diff --git a/src/lib/components/ui/select/select-label.svelte b/src/lib/components/ui/select/select-label.svelte new file mode 100644 index 0000000..4696025 --- /dev/null +++ b/src/lib/components/ui/select/select-label.svelte @@ -0,0 +1,20 @@ + + +
+ {@render children?.()} +
diff --git a/src/lib/components/ui/select/select-portal.svelte b/src/lib/components/ui/select/select-portal.svelte new file mode 100644 index 0000000..424bcdd --- /dev/null +++ b/src/lib/components/ui/select/select-portal.svelte @@ -0,0 +1,7 @@ + + + diff --git a/src/lib/components/ui/select/select-scroll-down-button.svelte b/src/lib/components/ui/select/select-scroll-down-button.svelte new file mode 100644 index 0000000..3629205 --- /dev/null +++ b/src/lib/components/ui/select/select-scroll-down-button.svelte @@ -0,0 +1,20 @@ + + + + + diff --git a/src/lib/components/ui/select/select-scroll-up-button.svelte b/src/lib/components/ui/select/select-scroll-up-button.svelte new file mode 100644 index 0000000..1aa2300 --- /dev/null +++ b/src/lib/components/ui/select/select-scroll-up-button.svelte @@ -0,0 +1,20 @@ + + + + + diff --git a/src/lib/components/ui/select/select-separator.svelte b/src/lib/components/ui/select/select-separator.svelte new file mode 100644 index 0000000..0eac3eb --- /dev/null +++ b/src/lib/components/ui/select/select-separator.svelte @@ -0,0 +1,18 @@ + + + diff --git a/src/lib/components/ui/select/select-trigger.svelte b/src/lib/components/ui/select/select-trigger.svelte new file mode 100644 index 0000000..dbb81df --- /dev/null +++ b/src/lib/components/ui/select/select-trigger.svelte @@ -0,0 +1,29 @@ + + + + {@render children?.()} + + diff --git a/src/lib/components/ui/select/select.svelte b/src/lib/components/ui/select/select.svelte new file mode 100644 index 0000000..05eb663 --- /dev/null +++ b/src/lib/components/ui/select/select.svelte @@ -0,0 +1,11 @@ + + + diff --git a/src/lib/components/ui/separator/index.ts b/src/lib/components/ui/separator/index.ts new file mode 100644 index 0000000..82442d2 --- /dev/null +++ b/src/lib/components/ui/separator/index.ts @@ -0,0 +1,7 @@ +import Root from "./separator.svelte"; + +export { + Root, + // + Root as Separator, +}; diff --git a/src/lib/components/ui/separator/separator.svelte b/src/lib/components/ui/separator/separator.svelte new file mode 100644 index 0000000..f40999f --- /dev/null +++ b/src/lib/components/ui/separator/separator.svelte @@ -0,0 +1,21 @@ + + + diff --git a/src/lib/date-utils.ts b/src/lib/date-utils.ts new file mode 100644 index 0000000..f8d7d5c --- /dev/null +++ b/src/lib/date-utils.ts @@ -0,0 +1,48 @@ +/** + * Utilities for converting between local date/time and UTC ISO strings for PocketBase. + * PocketBase stores dates in UTC (ISO 8601). We display and edit in the user's local timezone. + */ + +/** Format expected by : "YYYY-MM-DDTHH:mm" in local time */ +export const DATETIME_LOCAL_FORMAT = /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}$/; + +/** + * Convert a UTC ISO string (from PocketBase) to local datetime for datetime-local input. + * Handles both "2024-01-15T12:00:00.000Z" and "2024-01-15 12:00:00.000Z" styles. + */ +export function utcIsoToLocalDatetimeLocal(isoUtc: string | undefined): string { + if (!isoUtc || typeof isoUtc !== 'string') return ''; + const d = new Date(isoUtc); + if (Number.isNaN(d.getTime())) return ''; + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + const h = String(d.getHours()).padStart(2, '0'); + const min = String(d.getMinutes()).padStart(2, '0'); + return `${y}-${m}-${day}T${h}:${min}`; +} + +/** + * Convert current local date/time to datetime-local string (for default "now" when creating). + */ +export function nowToLocalDatetimeLocal(): string { + const d = new Date(); + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + const h = String(d.getHours()).padStart(2, '0'); + const min = String(d.getMinutes()).padStart(2, '0'); + return `${y}-${m}-${day}T${h}:${min}`; +} + +/** + * Parse a datetime-local value (local time) and return an ISO UTC string for PocketBase. + */ +export function localDatetimeLocalToUtcIso(localStr: string | undefined): string { + if (!localStr || typeof localStr !== 'string' || !DATETIME_LOCAL_FORMAT.test(localStr.trim())) { + return new Date().toISOString(); + } + const d = new Date(localStr); + if (Number.isNaN(d.getTime())) return new Date().toISOString(); + return d.toISOString(); +} diff --git a/src/routes/[id]/+page.server.ts b/src/routes/[id]/+page.server.ts index ef58869..bfe1516 100644 --- a/src/routes/[id]/+page.server.ts +++ b/src/routes/[id]/+page.server.ts @@ -21,12 +21,15 @@ export interface VoxerLink { url: string; } +export type ReportSentiment = 'Positive' | 'Negative' | 'Neutral'; + export interface EmployeeReportRecord { id: string; report?: string; voxers?: VoxerLink[] | string; // JSON array or string from PB created: string; updated: string; + Sentiment?: ReportSentiment; [key: string]: unknown; } diff --git a/src/routes/[id]/+page.svelte b/src/routes/[id]/+page.svelte index 1e20d5d..ba51b16 100644 --- a/src/routes/[id]/+page.svelte +++ b/src/routes/[id]/+page.svelte @@ -10,8 +10,15 @@ import type { EmployeeRecord, EmployeeReportRecord, + ReportSentiment, VoxerLink } from './+page.server'; + import * as Select from '$lib/components/ui/select/index.js'; + import { + utcIsoToLocalDatetimeLocal, + nowToLocalDatetimeLocal, + localDatetimeLocalToUtcIso + } from '$lib/date-utils'; import ArrowLeftIcon from '@lucide/svelte/icons/arrow-left'; import LinkIcon from '@lucide/svelte/icons/link'; import PencilIcon from '@lucide/svelte/icons/pencil'; @@ -48,6 +55,9 @@ let openDeleteReport = $state(false); let reportFormText = $state(''); let reportVoxers = $state([]); + const SENTIMENT_OPTIONS: ReportSentiment[] = ['Positive', 'Negative', 'Neutral']; + let reportCreatedLocal = $state(''); + let reportSentiment = $state('Neutral'); let editingReport = $state(null); let deletingReport = $state(null); let reportSaving = $state(false); @@ -143,6 +153,8 @@ function openCreateReportDialog() { reportFormText = ''; reportVoxers = []; + reportCreatedLocal = nowToLocalDatetimeLocal(); + reportSentiment = 'Neutral'; reportError = null; editingReport = null; openCreateReport = true; @@ -152,6 +164,8 @@ editingReport = report; reportFormText = report.report ?? ''; reportVoxers = parseVoxers(report).map((v) => ({ ...v })); + reportCreatedLocal = utcIsoToLocalDatetimeLocal(report.created); + reportSentiment = (report.Sentiment === 'Positive' || report.Sentiment === 'Negative' ? report.Sentiment : 'Neutral') as ReportSentiment; reportError = null; openEditReport = true; } @@ -177,7 +191,12 @@ const res = await fetch(`/api/employees/${employeeId}/reports`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ report: reportFormText, voxers: reportVoxers }) + body: JSON.stringify({ + report: reportFormText, + voxers: reportVoxers, + created: localDatetimeLocalToUtcIso(reportCreatedLocal), + Sentiment: reportSentiment + }) }); if (!res.ok) { const err = await res.json().catch(() => ({})); @@ -200,7 +219,12 @@ const res = await fetch(`/api/employees/${employeeId}/reports/${editingReport.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ report: reportFormText, voxers: reportVoxers }) + body: JSON.stringify({ + report: reportFormText, + voxers: reportVoxers, + created: localDatetimeLocalToUtcIso(reportCreatedLocal), + Sentiment: reportSentiment + }) }); if (!res.ok) throw new Error('Update failed'); openEditReport = false; @@ -416,6 +440,9 @@ Report

Created {formatDate(report.created)} · Updated {formatDate(report.updated)} + {#if report.Sentiment} + · {report.Sentiment} + {/if}

@@ -512,6 +539,30 @@ }} class="grid gap-4 py-4" > +
+
+ + +
+
+ + + + {reportSentiment} + + + {#each SENTIMENT_OPTIONS as opt} + {opt} + {/each} + + +
+