Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/frontend.yml
Original file line number Diff line number Diff line change
Expand Up @@ -60,6 +60,9 @@ jobs:
- name: Run Svelte typecheck
run: npm run typecheck

- name: Run frontend tests
run: npm run test:run

build:
name: Build
runs-on: ubuntu-latest
Expand Down
3 changes: 3 additions & 0 deletions .github/workflows/go.yml
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,9 @@ jobs:
- name: Run Go lint and architecture guards
run: make lint

- name: Run preference and calendar tests
run: go test ./internal/services ./internal/repository ./internal/restapi/v2 ./internal/i18n

govulncheck:
name: Go Vulnerability Check
runs-on: ubuntu-latest
Expand Down
3 changes: 3 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,9 @@ cx
*.spec.ts

# Public regression coverage for the locale runtime ships with the implementation.
!frontend/src/lib/stores/calendar.spec.js
!internal/services/calendar_preferences_test.go
!internal/restapi/v2/calendar_preferences_test.go
!frontend/src/lib/stores/i18n-utils.spec.js
!frontend/src/lib/mobile/mobile-i18n.spec.js
!frontend/scripts/hardcoded-i18n-ast.spec.js
Expand Down
15 changes: 15 additions & 0 deletions api/openapi-v2.json
Original file line number Diff line number Diff line change
Expand Up @@ -31390,6 +31390,10 @@
"data": {
"additionalProperties": false,
"properties": {
"calendar": {
"description": "The calendar value.",
"type": "string"
},
"color_mode": {
"description": "The color mode value.",
"type": "string"
Expand Down Expand Up @@ -31552,6 +31556,7 @@
}
},
"required": [
"calendar",
"color_mode",
"comment_sort_order",
"due_date_reminder_days",
Expand Down Expand Up @@ -59695,6 +59700,11 @@
"PatchUsersMePreferencesPayload": {
"additionalProperties": false,
"properties": {
"calendar": {
"description": "The calendar value.",
"nullable": true,
"type": "string"
},
"color_mode": {
"description": "The color mode value.",
"nullable": true,
Expand Down Expand Up @@ -59771,6 +59781,10 @@
"data": {
"additionalProperties": false,
"properties": {
"calendar": {
"description": "The calendar value.",
"type": "string"
},
"color_mode": {
"description": "The color mode value.",
"type": "string"
Expand Down Expand Up @@ -59933,6 +59947,7 @@
}
},
"required": [
"calendar",
"color_mode",
"comment_sort_order",
"due_date_reminder_days",
Expand Down
13 changes: 9 additions & 4 deletions frontend/src/lib/components/Input.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -115,21 +115,26 @@
{name}
{min}
{max}
{step}
{disabled}
{required}
{autofocus}
readonly={readonly}
{placeholder}
{size}
class={className}
{dataTestid}
{ariaLabel}
{ariaDescribedby}
{style}
{title}
{onclick}
{onkeyup}
{onkeydown}
{onblur}
{onfocus}
onchange={(next) => {
// Mirror the native input shape: consumers read either target.value or
// currentTarget.value.
const synthetic = { target: { value: next }, currentTarget: { value: next } };
onchange={(next, event) => {
const synthetic = event || { target: { value: next }, currentTarget: { value: next } };
oninput?.(synthetic);
onchange?.(synthetic);
}}
Expand Down
9 changes: 8 additions & 1 deletion frontend/src/lib/editors/RecurrenceEditor.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,8 @@
<script>
import { t } from '../stores/i18n.svelte.js';
import { getAppLocale } from '../stores/appLocale.svelte.js';
import { resolveCalendar } from '../stores/calendar.svelte.js';
import { formatDateWithOptions } from '../utils/dateFormatter.js';
import { recurrence } from '../api/recurrence.js';
import { parseRRule, buildRRule, rruleToText, DAY_NAMES, DAY_LABELS, FREQ_LABELS } from './rruleUtils.js';
import { errorToast } from '../stores/toasts.svelte.js';
Expand Down Expand Up @@ -175,7 +178,7 @@

function formatPreviewDate(dateStr) {
try {
return new Date(dateStr).toLocaleDateString(undefined, {
return formatDateWithOptions(dateStr, {
weekday: 'short',
year: 'numeric',
month: 'short',
Expand Down Expand Up @@ -241,6 +244,10 @@
</FormField>
</div>

{#if resolveCalendar(getAppLocale()) !== 'gregory' && (frequency === 'MONTHLY' || frequency === 'YEARLY')}
<p class="text-sm text-ds-text-subtle">{t('recurrence.gregorianRuleHint')}</p>
{/if}

{#if frequency === 'WEEKLY'}
<FormField label={t('recurrence.daysOfWeek')} class="mb-0">
<div class="flex flex-wrap gap-2">
Expand Down
3 changes: 2 additions & 1 deletion frontend/src/lib/features/agents/AgentCatalog.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
import { agentRuns } from '../../api/agentRuns.js';
import { workspacePermissions } from '../../stores/workspacePermissions.svelte.js';
import { t } from '../../stores/i18n.svelte.js';
import { formatDateSimple } from '../../utils/dateFormatter.js';
import PageHeader from '../../layout/PageHeader.svelte';
import Avatar from '../../components/Avatar.svelte';
import Badge from '../../components/Badge.svelte';
Expand Down Expand Up @@ -270,7 +271,7 @@
{t('workspaceAgents.catalog.lastRun', {
status: runStatusLabel(latestRun.status),
})} ·
{new Date(latestRun.updated_at || latestRun.created_at).toLocaleDateString()}
{formatDateSimple(new Date(latestRun.updated_at || latestRun.created_at))}
</span>
</div>
{/if}
Expand Down
93 changes: 41 additions & 52 deletions frontend/src/lib/features/collections/CollectionRoadmap.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,10 @@
import CalendarClock from '@lucide/svelte/icons/calendar-clock';
import RotateCcw from '@lucide/svelte/icons/rotate-ccw';
import { getVisibleColor } from '../../utils/colorUtils.js';
import { formatDateWithOptions } from '../../utils/dateFormatter.js';
import { calendarDate as toCalendarDate, calendarMonthStart, calendarMonthPosition, calendarDateAtPosition, civilDays } from './roadmapCalendar.js';
import { getAppLocale } from '../../stores/appLocale.svelte.js';
import { resolveCalendar } from '../../stores/calendar.svelte.js';
import ItemTypeIcon from '../../components/ItemTypeIcon.svelte';
import ItemKey from '../../components/ItemKey.svelte';
import { SYSTEM_FIELDS } from '../../stores/fieldConfig.js';
Expand Down Expand Up @@ -270,15 +274,16 @@
// Snap granularity in days: week/month zoom → 1 day, quarter zoom → 7 days
let snapDays = $derived(zoom === 'quarter' ? 7 : 1);

function calendarDate(date) { return toCalendarDate(date, resolveCalendar(getAppLocale())); }
function monthStart(date, offset = 0) { return calendarMonthStart(date, resolveCalendar(getAppLocale()), offset); }

// Reference date (start of visible range)
let referenceDate = $derived.by(() => {
const today = new Date();
today.setHours(0, 0, 0, 0);
const d = new Date(today);
if (zoom === 'quarter') {
d.setDate(1);
d.setMonth(d.getMonth() + scrollOffset);
return d;
return monthStart(d, scrollOffset);
}
d.setDate(d.getDate() + scrollOffset * zoomConfig.columnDays);
if (zoom === 'week' || zoom === 'month') {
Expand Down Expand Up @@ -315,7 +320,7 @@
for (let i = -prependCount; i < rightCount; i++) {
const d = new Date(referenceDate);
if (zoom === 'quarter') {
d.setMonth(referenceDate.getMonth() + i);
d.setTime(monthStart(referenceDate, i).getTime());
} else {
d.setDate(d.getDate() + i * zoomConfig.columnDays);
}
Expand All @@ -336,7 +341,7 @@
const last = columns[columns.length - 1];
const d = new Date(last);
if (zoom === 'quarter') {
d.setMonth(d.getMonth() + 1);
d.setTime(monthStart(d, 1).getTime());
} else {
d.setDate(d.getDate() + zoomConfig.columnDays);
}
Expand All @@ -346,18 +351,9 @@
// Convert a date to a column position (fractional column index)
function dateToColPos(date) {
if (zoom === 'quarter') {
const refYear = referenceDate.getFullYear();
const refMonth = referenceDate.getMonth();
const year = date.getFullYear();
const month = date.getMonth();
const monthOffset = (year - refYear) * 12 + (month - refMonth);
const monthStart = new Date(year, month, 1);
const nextMonth = new Date(year, month + 1, 1);
const daysInMonth = (nextMonth.getTime() - monthStart.getTime()) / (1000 * 60 * 60 * 24);
const dayInMonth = (date.getTime() - monthStart.getTime()) / (1000 * 60 * 60 * 24);
return monthOffset + dayInMonth / daysInMonth;
return calendarMonthPosition(referenceDate, date, resolveCalendar(getAppLocale()));
}
return (date.getTime() - referenceDate.getTime()) / (1000 * 60 * 60 * 24) / zoomConfig.columnDays;
return civilDays(date, referenceDate) / zoomConfig.columnDays;
}

// Today column index
Expand All @@ -371,17 +367,15 @@
let monthBoundaries = $derived.by(() => {
if (zoom !== 'month' || columns.length === 0) return [];
const boundaries = [];
const refTime = referenceDate.getTime();
const msPerDay = 1000 * 60 * 60 * 24;
// Find all month-starts within the visible range
const first = columns[0];
const last = columns[columns.length - 1];
const d = new Date(first.getFullYear(), first.getMonth(), 1);
if (d < first) d.setMonth(d.getMonth() + 1);
const d = monthStart(first);
if (d < first) d.setTime(monthStart(d, 1).getTime());
while (d <= last) {
const colPos = (d.getTime() - refTime) / msPerDay / zoomConfig.columnDays;
boundaries.push({ px: (colPos + gridOffset) * colWidth, label: d.toLocaleDateString(undefined, { month: 'short' }) });
d.setMonth(d.getMonth() + 1);
const colPos = civilDays(d, referenceDate) / zoomConfig.columnDays;
boundaries.push({ px: (colPos + gridOffset) * colWidth, label: formatDateWithOptions(d, { month: 'short' }) });
d.setTime(monthStart(d, 1).getTime());
}
return boundaries;
});
Expand All @@ -390,24 +384,24 @@
let headerLabels = $derived.by(() => {
if (zoom === 'week') {
return columns.map(d => ({
label: String(d.getDate()),
weekday: d.toLocaleDateString(undefined, { weekday: 'short' }),
label: formatDateWithOptions(d, { day: 'numeric' }),
weekday: formatDateWithOptions(d, { weekday: 'short' }),
sublabel: '',
date: d
}));
}
if (zoom === 'month') {
return columns.map(d => ({
label: `${d.getDate()}`,
label: formatDateWithOptions(d, { day: 'numeric' }),
weekday: '',
sublabel: d.toLocaleDateString(undefined, { month: 'short' }),
sublabel: formatDateWithOptions(d, { month: 'short' }),
date: d
}));
}
return columns.map(d => ({
label: d.toLocaleDateString(undefined, { month: 'short' }),
label: formatDateWithOptions(d, { month: 'short' }),
weekday: '',
sublabel: String(d.getFullYear()),
sublabel: formatDateWithOptions(d, { year: 'numeric' }),
date: d
}));
});
Expand All @@ -417,24 +411,22 @@
const groups = [];
if (zoom === 'month' && columns.length > 0) {
// Use actual calendar month boundaries for pixel-accurate positioning
const refTime = referenceDate.getTime();
const msPerDay = 1000 * 60 * 60 * 24;
const totalPx = columns.length * colWidth;

const lastCol = columns[columns.length - 1];
const endVisible = new Date(lastCol);
endVisible.setDate(endVisible.getDate() + zoomConfig.columnDays);

let d = new Date(columns[0].getFullYear(), columns[0].getMonth(), 1);
let d = monthStart(columns[0]);
while (d <= endVisible) {
const nextMonth = new Date(d.getFullYear(), d.getMonth() + 1, 1);
const leftDays = (d.getTime() - refTime) / msPerDay;
const rightDays = (nextMonth.getTime() - refTime) / msPerDay;
const nextMonth = monthStart(d, 1);
const leftDays = civilDays(d, referenceDate);
const rightDays = civilDays(nextMonth, referenceDate);
const leftPx = Math.max(0, (leftDays / zoomConfig.columnDays + gridOffset) * colWidth);
const rightPx = Math.min(totalPx, (rightDays / zoomConfig.columnDays + gridOffset) * colWidth);
if (rightPx > leftPx) {
groups.push({
label: d.toLocaleDateString(undefined, { month: 'long', year: 'numeric' }),
label: formatDateWithOptions(d, { month: 'long', year: 'numeric' }),
leftPx,
widthPx: rightPx - leftPx,
});
Expand All @@ -447,8 +439,8 @@
for (let i = 0; i < columns.length; i++) {
const d = columns[i];
const key = zoom === 'quarter'
? `Q${Math.floor(d.getMonth() / 3) + 1} ${d.getFullYear()}`
: d.toLocaleDateString(undefined, { month: 'long', year: 'numeric' });
? `Q${Math.floor((calendarDate(d).month - 1) / 3) + 1} ${calendarDate(d).year}`
: formatDateWithOptions(d, { month: 'long', year: 'numeric' });
if (key !== currentMonth) {
groups.push({ label: key, leftPx: i * colWidth, widthPx: colWidth });
currentMonth = key;
Expand Down Expand Up @@ -812,11 +804,14 @@
function onDragMove(e) {
if (!dragInfo) return;
const dx = e.clientX - dragInfo.startX;
const pxPerDay = zoom === 'quarter'
? colWidth / 30
: colWidth / zoomConfig.columnDays;
const rawDays = dx / pxPerDay;
dragInfo.daysDelta = Math.round(rawDays / snapDays) * snapDays;
if (zoom === 'quarter') {
const anchor = dragInfo.mode === 'resize-right' ? dragInfo.origEnd : dragInfo.origStart;
const position = (anchor + gridOffset) * colWidth;
dragInfo.daysDelta = civilDays(dateAtTimelineX(position + dx), dateAtTimelineX(position));
} else {
const rawDays = dx / (colWidth / zoomConfig.columnDays);
dragInfo.daysDelta = Math.round(rawDays / snapDays) * snapDays;
}
}

async function onDragEnd() {
Expand Down Expand Up @@ -954,13 +949,7 @@
const dayOffset = timelineX / colWidth - gridOffset;
const date = new Date(referenceDate);
if (zoom === 'quarter') {
const monthOffset = Math.floor(dayOffset);
const frac = dayOffset - monthOffset;
date.setMonth(date.getMonth() + monthOffset);
const nextMonth = new Date(date.getFullYear(), date.getMonth() + 1, 1);
const monthStart = new Date(date.getFullYear(), date.getMonth(), 1);
const daysInMonth = (nextMonth.getTime() - monthStart.getTime()) / (1000 * 60 * 60 * 24);
date.setDate(1 + Math.floor(frac * daysInMonth / snapDays) * snapDays);
return calendarDateAtPosition(referenceDate, dayOffset, resolveCalendar(getAppLocale()), snapDays);
} else {
const days = Math.floor(dayOffset * zoomConfig.columnDays / snapDays) * snapDays;
date.setDate(date.getDate() + days);
Expand Down Expand Up @@ -1608,8 +1597,8 @@
{/if}
{:else if schedulePreview?.itemId === item.id}
{@const previewColor = getVisibleColor(statuses.find(s => s.id === item.status_id)?.color || '#6b7280')}
{@const previewStart = schedulePreview.start.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
{@const previewEnd = schedulePreview.end?.toLocaleDateString(undefined, { month: 'short', day: 'numeric' })}
{@const previewStart = formatDateWithOptions(schedulePreview.start, { month: 'short', day: 'numeric' })}
{@const previewEnd = formatDateWithOptions(schedulePreview.end, { month: 'short', day: 'numeric' })}
<div
data-testid="roadmap-schedule-preview-{item.id}"
data-start-date={formatDateValue(schedulePreview.start)}
Expand Down
Loading
Loading