Add date difference calculation functionality (#10673)

* Add date difference calculation functionality

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>

* Fix

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>

* Fix formatting

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>

---------

Signed-off-by: Denis Bykhov <bykhov.denis@gmail.com>
This commit is contained in:
Denis Bykhov
2026-03-24 00:49:55 +05:00
committed by GitHub
parent 6f14b3d73a
commit 31246a11a0
23 changed files with 258 additions and 44 deletions
+14
View File
@@ -610,6 +610,20 @@ export function defineFunctions (builder: Builder): void {
process.function.DateFromNumber
)
builder.createDoc(
process.class.ProcessFunction,
core.space.Model,
{
of: core.class.TypeDate,
to: core.class.TypeNumber,
category: 'attribute',
label: process.string.DateDifference,
type: 'convert',
editor: process.transformEditor.DateDifferenceEditor
},
process.function.DateDifference
)
builder.createDoc(
process.class.ProcessFunction,
core.space.Model,
+4
View File
@@ -211,6 +211,10 @@ export function createModel (builder: Builder): void {
func: serverProcess.transform.DayFromDate
})
builder.mixin(process.function.DateDifference, process.class.ProcessFunction, serverProcess.mixin.FuncImpl, {
func: serverProcess.transform.DateDifference
})
builder.mixin(process.function.Random, process.class.ProcessFunction, serverProcess.mixin.FuncImpl, {
func: serverProcess.transform.Random
})
+2 -1
View File
@@ -162,7 +162,8 @@
"DateFromText": "Datum ze stringu",
"YearFromDate": "Rok z data",
"MonthFromDate": "Měsíc z data",
"DayFromDate": "Den z data"
"DayFromDate": "Den z data",
"DateDifference": "Rozdíl dat"
},
"error": {
"MethodNotFound": "Metoda nenalezena: {methodId}",
+2 -1
View File
@@ -162,7 +162,8 @@
"DateFromText": "Datum aus Text",
"YearFromDate": "Jahr aus Datum",
"MonthFromDate": "Monat aus Datum",
"DayFromDate": "Tag aus Datum"
"DayFromDate": "Tag aus Datum",
"DateDifference": "Datumsdifferenz"
},
"error": {
"MethodNotFound": "Methode nicht gefunden: {methodId}",
+2 -1
View File
@@ -167,7 +167,8 @@
"DateFromText": "Date from text",
"YearFromDate": "Year from date",
"MonthFromDate": "Month from date",
"DayFromDate": "Day from date"
"DayFromDate": "Day from date",
"DateDifference": "Date difference"
},
"error": {
"MethodNotFound": "Method not found: {methodId}",
+2 -1
View File
@@ -167,7 +167,8 @@
"DateFromText": "Fecha desde texto",
"YearFromDate": "Año desde fecha",
"MonthFromDate": "Mes desde fecha",
"DayFromDate": "Día desde fecha"
"DayFromDate": "Día desde fecha",
"DateDifference": "Diferencia de fechas"
},
"error": {
"MethodNotFound": "Método no encontrado: {methodId}",
+2 -1
View File
@@ -167,7 +167,8 @@
"DateFromText": "Date depuis texte",
"YearFromDate": "Année depuis date",
"MonthFromDate": "Mois depuis date",
"DayFromDate": "Jour depuis date"
"DayFromDate": "Jour depuis date",
"DateDifference": "Différence de dates"
},
"error": {
"MethodNotFound": "Méthode introuvable : {methodId}",
+2 -1
View File
@@ -167,7 +167,8 @@
"DateFromText": "Data da testo",
"YearFromDate": "Anno da data",
"MonthFromDate": "Mese da data",
"DayFromDate": "Giorno da data"
"DayFromDate": "Giorno da data",
"DateDifference": "Differenza di date"
},
"error": {
"MethodNotFound": "Metodo non trovato: {methodId}",
+2 -1
View File
@@ -166,7 +166,8 @@
"DateFromText": "テキストから日付",
"YearFromDate": "日付から年",
"MonthFromDate": "日付から月",
"DayFromDate": "日付から日"
"DayFromDate": "日付から日",
"DateDifference": "日付の差"
},
"error": {
"MethodNotFound": "メソッドが見つかりません: {methodId}",
+2 -1
View File
@@ -155,7 +155,8 @@
"DateFromText": "Data de texto",
"YearFromDate": "Ano de data",
"MonthFromDate": "Mês de data",
"DayFromDate": "Dia de data"
"DayFromDate": "Dia de data",
"DateDifference": "Diferença de datas"
},
"error": {
"MethodNotFound": "Método não encontrado: {methodId}",
+2 -1
View File
@@ -167,7 +167,8 @@
"DateFromText": "Data de texto",
"YearFromDate": "Ano de data",
"MonthFromDate": "Mês de data",
"DayFromDate": "Dia de data"
"DayFromDate": "Dia de data",
"DateDifference": "Diferença de datas"
},
"error": {
"MethodNotFound": "Método não encontrado: {methodId}",
+4 -3
View File
@@ -163,11 +163,12 @@
"TextFromCheckbox": "Строка из чекбокса",
"NumberFromDate": "Число из даты",
"DateFromNumber": "Дата из числа",
"NumberFromString": "Число из строки",
"DateFromString": "Дата из строки",
"NumberFromText": "Число из текста",
"DateFromText": "Дата из текста",
"YearFromDate": "Год из даты",
"MonthFromDate": "Месяц из даты",
"DayFromDate": "День из даты"
"DayFromDate": "День из даты",
"DateDifference": "Разница дат"
},
"error": {
"MethodNotFound": "Метод не найден: {methodId}",
+2 -1
View File
@@ -162,7 +162,8 @@
"DateFromText": "Metinden tarih",
"YearFromDate": "Tarihten yıl",
"MonthFromDate": "Tarihten ay",
"DayFromDate": "Tarihten gün"
"DayFromDate": "Tarihten gün",
"DateDifference": "Tarih farkı"
},
"error": {
"MethodNotFound": "Metod bulunamadı: {methodId}",
+2 -1
View File
@@ -167,7 +167,8 @@
"DateFromText": "文本到日期",
"YearFromDate": "日期到年份",
"MonthFromDate": "日期到月份",
"DayFromDate": "日期到天"
"DayFromDate": "日期到天",
"DateDifference": "日期差异"
},
"error": {
"MethodNotFound": "找不到方法:{methodId}",
@@ -165,8 +165,12 @@
<!-- svelte-ignore a11y-mouse-events-have-key-events -->
<button
bind:this={elements[0]}
on:keydown={(event) => keyDown(event, 0)}
on:mouseover={() => elements[0]?.focus()}
on:keydown={(event) => {
keyDown(event, 0)
}}
on:mouseover={() => {
elements[0]?.focus()
}}
on:click={() => {
onUserRequest()
}}
@@ -183,8 +187,12 @@
{#if func.editor !== undefined}
<Submenu
bind:element={elements[functionsOffset + i]}
on:keydown={(event) => keyDown(event, functionsOffset + i)}
on:mouseover={() => elements[functionsOffset + i]?.focus()}
on:keydown={(event) => {
keyDown(event, functionsOffset + i)
}}
on:mouseover={() => {
elements[functionsOffset + i]?.focus()
}}
label={func.label}
props={{
masterTag,
@@ -199,8 +207,12 @@
<!-- svelte-ignore a11y-mouse-events-have-key-events -->
<button
bind:this={elements[functionsOffset + i]}
on:keydown={(event) => keyDown(event, functionsOffset + i)}
on:mouseover={() => elements[functionsOffset + i]?.focus()}
on:keydown={(event) => {
keyDown(event, functionsOffset + i)
}}
on:mouseover={() => {
elements[functionsOffset + i]?.focus()
}}
on:click={() => {
onFunc(func._id)
}}
@@ -219,8 +231,12 @@
{#if pc.attributes.length > 0}
<Submenu
bind:element={elements[processContextOffset + i]}
on:keydown={(event) => keyDown(event, processContextOffset + i)}
on:mouseover={() => elements[processContextOffset + i]?.focus()}
on:keydown={(event) => {
keyDown(event, processContextOffset + i)
}}
on:mouseover={() => {
elements[processContextOffset + i]?.focus()
}}
component={ExecutionContextPresenter}
props={{
context: pc,
@@ -236,8 +252,12 @@
<!-- svelte-ignore a11y-mouse-events-have-key-events -->
<button
bind:this={elements[processContextOffset + i]}
on:keydown={(event) => keyDown(event, processContextOffset + i)}
on:mouseover={() => elements[processContextOffset + i]?.focus()}
on:keydown={(event) => {
keyDown(event, processContextOffset + i)
}}
on:mouseover={() => {
elements[processContextOffset + i]?.focus()
}}
on:click={() => {
onProcessContext(pc)
}}
@@ -254,8 +274,12 @@
<!-- svelte-ignore a11y-mouse-events-have-key-events -->
<button
bind:this={elements[attributesOffset + i]}
on:keydown={(event) => keyDown(event, attributesOffset + i)}
on:mouseover={() => elements[attributesOffset + i]?.focus()}
on:keydown={(event) => {
keyDown(event, attributesOffset + i)
}}
on:mouseover={() => {
elements[attributesOffset + i]?.focus()
}}
on:click={() => {
onAttribute(attr)
}}
@@ -272,8 +296,12 @@
{#each nested as object, i}
<Submenu
bind:element={elements[nestedOffset + i]}
on:keydown={(event) => keyDown(event, nestedOffset + i)}
on:mouseover={() => elements[nestedOffset + i]?.focus()}
on:keydown={(event) => {
keyDown(event, nestedOffset + i)
}}
on:mouseover={() => {
elements[nestedOffset + i]?.focus()
}}
label={object.attribute.label}
props={{
context: object,
@@ -292,8 +320,12 @@
<!-- svelte-ignore a11y-mouse-events-have-key-events -->
<button
bind:this={elements[relationsOffset + i]}
on:keydown={(event) => keyDown(event, relationsOffset + i)}
on:mouseover={() => elements[relationsOffset + i]?.focus()}
on:keydown={(event) => {
keyDown(event, relationsOffset + i)
}}
on:mouseover={() => {
elements[relationsOffset + i]?.focus()
}}
on:click={() => {
onRelation(object[1])
}}
@@ -306,8 +338,12 @@
{:else}
<Submenu
bind:element={elements[relationsOffset + i]}
on:keydown={(event) => keyDown(event, relationsOffset + i)}
on:mouseover={() => elements[relationsOffset + i]?.focus()}
on:keydown={(event) => {
keyDown(event, relationsOffset + i)
}}
on:mouseover={() => {
elements[relationsOffset + i]?.focus()
}}
text={object[1].name}
props={{
context: object[1],
@@ -324,8 +360,12 @@
{#each convertible as conv, i}
<Submenu
bind:element={elements[convertibleOffset + i]}
on:keydown={(event) => keyDown(event, convertibleOffset + i)}
on:mouseover={() => elements[convertibleOffset + i]?.focus()}
on:keydown={(event) => {
keyDown(event, convertibleOffset + i)
}}
on:mouseover={() => {
elements[convertibleOffset + i]?.focus()
}}
label={getFunc(conv.func).label}
props={{
process,
@@ -344,8 +384,12 @@
<!-- svelte-ignore a11y-mouse-events-have-key-events -->
<button
bind:this={elements[customValueIndex]}
on:keydown={(event) => keyDown(event, customValueIndex)}
on:mouseover={() => elements[customValueIndex]?.focus()}
on:keydown={(event) => {
keyDown(event, customValueIndex)
}}
on:mouseover={() => {
elements[customValueIndex]?.focus()
}}
on:click={onConst}
class="menu-item"
>
@@ -0,0 +1,107 @@
<!--
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
-->
<script lang="ts">
import { MasterTag, Tag } from '@hcengineering/card'
import core, { AnyAttribute, Ref } from '@hcengineering/core'
import { getResource } from '@hcengineering/platform'
import presentation, { Card, getClient } from '@hcengineering/presentation'
import { Context, Process, ProcessFunction } from '@hcengineering/process'
import ui, { AnySvelteComponent, DropdownIntlItem, DropdownLabelsIntl } from '@hcengineering/ui'
import view from '@hcengineering/view'
import { createEventDispatcher } from 'svelte'
import ProcessAttribute from '../ProcessAttribute.svelte'
import { Analytics } from '@hcengineering/analytics'
export let func: ProcessFunction
export let process: Process
export let masterTag: Ref<MasterTag | Tag>
export let context: Context
export let attribute: AnyAttribute
export let props: Record<string, any> = {}
let otherDate = props?.otherDate
let unit = props?.unit ?? 'days'
const items: DropdownIntlItem[] = [
{ id: 'hours', label: ui.string.Hours },
{ id: 'days', label: ui.string.Days },
{ id: 'weeks', label: ui.string.Weeks },
{ id: 'months', label: ui.string.Months },
{ id: 'years', label: ui.string.Years }
]
const dispatch = createEventDispatcher()
const client = getClient()
const h = client.getHierarchy()
function save (): void {
dispatch('close', { otherDate, unit })
}
let editor: AnySvelteComponent | undefined
function getEditor (): void {
try {
const inlineEditor = h.as(h.getClass(core.class.TypeDate), view.mixin.AttributeEditor).inlineEditor
void getResource(inlineEditor)
.then((p) => {
editor = p
})
.catch((e: any) => {
Analytics.handleError(e)
})
} catch (e: any) {
Analytics.handleError(e)
}
}
getEditor()
</script>
<Card on:close width={'small'} label={func.label} canSave okAction={save} okLabel={presentation.string.Save}>
<div class="grid">
<ProcessAttribute
{process}
{masterTag}
{context}
attribute={{ ...attribute, label: ui.string.TargetDate }}
presenterClass={{
attrClass: core.class.TypeDate,
category: 'attribute'
}}
value={otherDate}
{editor}
on:change={(e) => {
otherDate = e.detail
}}
/>
<div class="flex-row-center flex-gap-2">
<DropdownLabelsIntl {items} bind:selected={unit} />
</div>
</div>
</Card>
<style lang="scss">
.grid {
display: grid;
width: 100%;
grid-template-columns: 1fr;
grid-auto-rows: minmax(1rem, max-content);
justify-content: start;
align-items: center;
row-gap: 0.75rem;
height: min-content;
}
</style>
+4 -2
View File
@@ -112,7 +112,8 @@ export default mergeIds(processId, process, {
ReplaceEditor: '' as AnyComponent,
SplitEditor: '' as AnyComponent,
CutEditor: '' as AnyComponent,
FilterEditor: '' as AnyComponent
FilterEditor: '' as AnyComponent,
DateDifferenceEditor: '' as AnyComponent
},
transformPresenter: {
NumberPresenter: '' as AnyComponent,
@@ -272,7 +273,8 @@ export default mergeIds(processId, process, {
DateFromText: '' as IntlString,
YearFromDate: '' as IntlString,
MonthFromDate: '' as IntlString,
DayFromDate: '' as IntlString
DayFromDate: '' as IntlString,
DateDifference: '' as IntlString
},
permission: {
RunProcess: '' as Ref<Permission>,
+1 -1
View File
@@ -320,7 +320,7 @@ function getContextFunctions (
const matched: Array<Ref<ProcessFunction>> = []
const hierarchy = client.getHierarchy()
const funcs = client.getModel().findAllSync(process.class.ProcessFunction, { type: 'context' })
for (const func of (funcs as unknown as ProcessFunction[])) {
for (const func of funcs as unknown as ProcessFunction[]) {
switch (category) {
case 'array': {
if (func.category === 'array') {
+1
View File
@@ -385,6 +385,7 @@ export default plugin(processId, {
YearFromDate: '' as Ref<ProcessFunction>,
MonthFromDate: '' as Ref<ProcessFunction>,
DayFromDate: '' as Ref<ProcessFunction>,
DateDifference: '' as Ref<ProcessFunction>,
ExportProcess: '' as Resource<ExportFunc>,
CheckProcessSectionVisibility: '' as Resource<(doc: Card) => Promise<boolean>>
}
@@ -378,7 +378,7 @@
: resultQuery[groupByKey]?.$in?.length !== 0
? undefined
: []
: category ?? null
: (category ?? null)
}
}
</script>
@@ -97,7 +97,8 @@ import {
DateFromString,
YearFromDate,
MonthFromDate,
DayFromDate
DayFromDate,
DateDifference
} from './transform'
import {
RunSubProcess,
@@ -663,7 +664,8 @@ export default async () => ({
DateFromString,
YearFromDate,
MonthFromDate,
DayFromDate
DayFromDate,
DateDifference
},
rollbacks: {
ToDoCloseRollback,
@@ -246,6 +246,34 @@ export function Offset (val: Timestamp, props: Record<string, any>): Timestamp {
return val
}
export async function DateDifference (
value: Timestamp,
props: Record<string, any>,
control: ProcessControl,
execution: Execution
): Promise<number> {
if (typeof value !== 'number') return 0
const otherDate = await getContextValue(props.otherDate, control, execution)
if (typeof otherDate !== 'number') return 0
const diff = value - otherDate
const v = new Date(value)
const o = new Date(otherDate)
switch (props.unit) {
case 'hours':
return Math.floor(diff / (1000 * 60 * 60))
case 'weeks':
return Math.floor(diff / (1000 * 60 * 60 * 24 * 7))
case 'months':
return (v.getUTCFullYear() - o.getUTCFullYear()) * 12 + (v.getUTCMonth() - o.getUTCMonth())
case 'years':
return v.getUTCFullYear() - o.getUTCFullYear()
case 'days':
default:
return Math.floor(diff / (1000 * 60 * 60 * 24))
}
}
// #endregion
// #region Numbers
@@ -508,4 +536,3 @@ export function DayFromDate (value: Date): number {
}
// #endregion
+2 -1
View File
@@ -123,7 +123,8 @@ export default plugin(serverProcessId, {
DateFromString: '' as Resource<TransformFunc>,
YearFromDate: '' as Resource<TransformFunc>,
MonthFromDate: '' as Resource<TransformFunc>,
DayFromDate: '' as Resource<TransformFunc>
DayFromDate: '' as Resource<TransformFunc>,
DateDifference: '' as Resource<TransformFunc>
},
trigger: {
OnTransition: '' as Resource<TriggerFunc>,