Skip to content

Commit 4c58c5a

Browse files
committed
fix: close 0.8.6 temporal and reliability gaps
1 parent a96d6da commit 4c58c5a

69 files changed

Lines changed: 542 additions & 330 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

docs/temporal-semantics.md

Lines changed: 72 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,72 @@
1+
# Temporal semantics
2+
3+
Windshift uses four distinct temporal types. Callers must identify which type
4+
they have before parsing, formatting, comparing, or persisting it.
5+
6+
## Instants
7+
8+
An instant identifies one point on the UTC timeline. API timestamps and Unix
9+
timestamps are instants.
10+
11+
- Persist and exchange instants as UTC timestamps.
12+
- Authenticated surfaces display instants in the acting user's validated IANA
13+
timezone.
14+
- If a stored user timezone is missing or invalid, display the instant in UTC.
15+
- Public surfaces without an acting user display instants in UTC unless the
16+
surface explicitly owns another timezone.
17+
- Browser and server local timezones are never implicit presentation defaults.
18+
19+
Frontend code formats instants through `formatInstant` or a formatter returned
20+
by `createTemporalFormatter`. Backend code uses `ResolveTimezoneOrUTC` only at
21+
boundaries where invalid stored user data must safely fall back.
22+
23+
## Date-only values
24+
25+
A date-only value is a Gregorian calendar label in `YYYY-MM-DD` form. Due
26+
dates, iteration dates, leave dates, and custom date fields are date-only unless
27+
their API explicitly says otherwise.
28+
29+
- Do not convert date-only values through browser or user timezones.
30+
- Preserve the stored year, month, and day when formatting.
31+
- Do not infer a midnight instant from a date-only value except at a boundary
32+
that explicitly converts a civil range to instants.
33+
34+
Frontend code formats these values through `formatDateOnly`.
35+
36+
## Schedule-local civil time
37+
38+
A civil time combines calendar fields with an IANA timezone. Recurrences,
39+
on-call handoffs, and other schedules retain their own timezone even when the
40+
viewer uses a different timezone.
41+
42+
- Validate schedule and request-supplied timezone names strictly.
43+
- Reject nonexistent or ambiguous wall-clock times unless that feature has a
44+
documented DST policy.
45+
- Do not replace a schedule timezone with the user's display timezone.
46+
47+
Backend code uses `ResolveTimezone`, `ParseCivilDate`, and the feature's civil
48+
clock resolver for these values.
49+
50+
## Civil date ranges
51+
52+
User-facing inclusive date ranges become half-open instant ranges before they
53+
reach timestamp queries:
54+
55+
```text
56+
[start date at 00:00 local, day after end date at 00:00 local)
57+
```
58+
59+
Convert both boundaries to UTC after constructing them in the relevant IANA
60+
timezone. Advance the exclusive boundary with calendar-day arithmetic, not a
61+
24-hour duration, so DST transition days remain correct. Backend code uses
62+
`CivilDateRangeUTC` for this conversion.
63+
64+
## Durations
65+
66+
A duration is elapsed time and has no timezone. Format and compare durations
67+
without calendar conversion. Labels such as "3 hours ago" compare instants but
68+
express the resulting duration.
69+
70+
For worklogs, a duration submitted without explicit start and end clocks is
71+
anchored at the start of the submitted civil date in the resolved worklog
72+
timezone. Cookie, REST, and MCP entry points use this same deterministic rule.

frontend/src/lib/dialogs/ChannelConfigModal.svelte

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@
2121
import ChannelSMTPConfig from '../features/channels/ChannelSMTPConfig.svelte';
2222
import ChannelFormConfig from '../features/channels/ChannelFormConfig.svelte';
2323
import FormBuilder from '../features/channels/FormBuilder.svelte';
24+
import { formatAuthenticatedDateTime } from '../utils/authenticatedDateFormatter.js';
2425
2526
let {
2627
isOpen = false,
@@ -681,7 +682,7 @@
681682
{#if channel.last_activity}
682683
<div class="pt-6 mt-6 border-t" style="border-color: var(--ds-border);">
683684
<div class="text-sm" style="color: var(--ds-text-subtle);">
684-
{t('channel.lastActivity')}: {new Date(channel.last_activity).toLocaleString()}
685+
{t('channel.lastActivity')}: {formatAuthenticatedDateTime(channel.last_activity)}
685686
</div>
686687
</div>
687688
{/if}

frontend/src/lib/dialogs/TestCaseViewModal.svelte

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -14,7 +14,7 @@
1414
import Modal from './Modal.svelte';
1515
import Button from '../components/Button.svelte';
1616
import { api } from '../api.js';
17-
import { formatDateTimeLocale } from '../utils/dateFormatter.js';
17+
import { formatAuthenticatedDateTime as formatDateTimeLocale } from '../utils/authenticatedDateFormatter.js';
1818
import { t } from '../stores/i18n.svelte.js';
1919
2020
let {

frontend/src/lib/dialogs/TimeLogModal.svelte

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313
import Modal from './Modal.svelte';
1414
import DialogFooter from './DialogFooter.svelte';
1515
import { t } from '../stores/i18n.svelte.js';
16-
import { formatDate } from '../utils/dateFormatter.js';
16+
import { formatDate, worklogDateKey } from '../utils/dateFormatter.js';
1717
1818
// Configuration props
1919
let {
@@ -57,7 +57,7 @@
5757
project_id: editingWorklog?.project_id ?? defaultProjectId,
5858
item_id: editingWorklog?.item_id ?? defaultItemId,
5959
description: editingWorklog?.description ?? '',
60-
date: editingWorklog ? formatDate(new Date(editingWorklog.date * 1000)) : (defaultDate ?? formatDate(new Date())),
60+
date: editingWorklog ? worklogDateKey(editingWorklog.date) : (defaultDate ?? formatDate(new Date())),
6161
start_time: editingWorklog ? formatTimeFromUnix(editingWorklog.start_time) : (defaultStartTime ?? ''),
6262
end_time: editingWorklog ? formatTimeFromUnix(editingWorklog.end_time) : '',
6363
duration: editingWorklog ? formatDurationFromMinutes(editingWorklog.duration_minutes) : ''

frontend/src/lib/dialogs/TimeProjectPermissionsModal.svelte

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@
99
import { api } from '../api.js';
1010
import { t } from '../stores/i18n.svelte.js';
1111
import { errorToast } from '../stores/toasts.svelte.js';
12-
import { formatDateTimeLocale } from '../utils/dateFormatter.js';
12+
import { formatAuthenticatedDateTime as formatDateTimeLocale } from '../utils/authenticatedDateFormatter.js';
1313
import { User, Users, X, Plus, Shield, UserCheck } from '@lucide/svelte';
1414
import DescriptionText from '../components/DescriptionText.svelte';
1515
@@ -338,4 +338,3 @@
338338
</div>
339339
</div>
340340
</Modal>
341-

frontend/src/lib/editors/InlineDateEditor.svelte

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@
22
import { tick } from 'svelte';
33
import { Calendar } from '@lucide/svelte';
44
import { t } from '../stores/i18n.svelte.js';
5-
import { formatDate, formatDateShort } from '../utils/dateFormatter.js';
5+
import { dateOnlyKey, formatDateOnly } from '../utils/dateFormatter.js';
66
import BaseInlineEditor from './BaseInlineEditor.svelte';
77
import Input from '../components/Input.svelte';
88
@@ -14,15 +14,15 @@
1414
} = $props();
1515
1616
const effectivePlaceholder = $derived(placeholder || t('editors.selectDate'));
17-
const displayValue = $derived(value ? (formatDateShort(value) || value) : '');
17+
const displayValue = $derived(value ? (formatDateOnly(value) || value) : '');
1818
1919
let baseEditor;
2020
let editValue = $state('');
2121
let inputElement = $state(null);
2222
2323
function formatInputDate(dateStr) {
2424
if (!dateStr) return '';
25-
try { return formatDate(new Date(dateStr)); } catch { return ''; }
25+
return dateOnlyKey(dateStr);
2626
}
2727
2828
function handleStartEdit() {

frontend/src/lib/features/actions/ActionLogs.svelte

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,6 +6,7 @@
66
import Button from '../../components/Button.svelte';
77
import ExecutionTraceModal from './ExecutionTraceModal.svelte';
88
import { ArrowLeft, CheckCircle, XCircle, Clock, SkipForward, Eye } from '@lucide/svelte';
9+
import { formatAuthenticatedDateTime } from '../../utils/authenticatedDateFormatter.js';
910
1011
let { workspaceId, action, onBack } = $props();
1112
@@ -65,7 +66,7 @@
6566
6667
function formatDate(dateStr) {
6768
if (!dateStr) return '-';
68-
return new Date(dateStr).toLocaleString();
69+
return formatAuthenticatedDateTime(dateStr);
6970
}
7071
</script>
7172

frontend/src/lib/features/agents/AgentProfile.svelte

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@
3434
import Tabs from '../../components/Tabs.svelte';
3535
import Textarea from '../../components/Textarea.svelte';
3636
import ConfirmDialog from '../../dialogs/ConfirmDialog.svelte';
37+
import { formatAuthenticatedDateTime } from '../../utils/authenticatedDateFormatter.js';
3738
import AgentRunnerSetup from './AgentRunnerSetup.svelte';
3839
3940
let { workspaceId, agentId, tab = 'overview' } = $props();
@@ -752,7 +753,7 @@
752753
</p>
753754
{#if agent.updated_at}
754755
<p class="mt-4 text-xs" style="color: var(--ds-text-subtlest);">
755-
Updated {new Date(agent.updated_at).toLocaleString()}
756+
Updated {formatAuthenticatedDateTime(agent.updated_at)}
756757
</p>
757758
{/if}
758759
</Card>

frontend/src/lib/features/analytics/analyticsView.js

Lines changed: 2 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
export { formatDateOnly } from '../../utils/dateFormatter.js';
2+
13
export const ANALYTICS_MAX_RANGE_DAYS = 366;
24
export const ANALYTICS_DEFAULT_RANGE_DAYS = 84;
35

@@ -57,17 +59,6 @@ export function validateAnalyticsRange(startDate, endDate) {
5759
return null;
5860
}
5961

60-
export function formatDateOnly(dateString, options = {}) {
61-
const parts = dateParts(dateString);
62-
if (!parts) return '—';
63-
return new Intl.DateTimeFormat(undefined, {
64-
month: 'short',
65-
day: 'numeric',
66-
timeZone: 'UTC',
67-
...options,
68-
}).format(new Date(parts.timestamp));
69-
}
70-
7162
export function formatDayNumber(value) {
7263
const number = Number(value) || 0;
7364
return new Intl.NumberFormat(undefined, {

frontend/src/lib/features/collections/BoardItemCard.svelte

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import Avatar from '../../components/Avatar.svelte';
44
import DropIndicator from '../../layout/DropIndicator.svelte';
55
import DropdownMenu from '../../layout/DropdownMenu.svelte';
6-
import { formatDateShort } from '../../utils/dateFormatter.js';
6+
import { formatDateOnly } from '../../utils/dateFormatter.js';
77
import { itemTypeIconMap } from '../../utils/icons.js';
88
import ItemKey from '../items/ItemKey.svelte';
99
import CardFieldChip from './CardFieldChip.svelte';
@@ -159,7 +159,7 @@
159159
title="Due date"
160160
>
161161
<CalendarDays class="h-3 w-3 shrink-0" />
162-
{formatDateShort(item.due_date)}
162+
{formatDateOnly(item.due_date)}
163163
</span>
164164
{/if}
165165
<DependencySummary {item} links={dependencyLinks} />

0 commit comments

Comments
 (0)