calendar
Gitea Actions Demo / Deploy (push) Successful in 34s

This commit is contained in:
vadim committed 2026-10-12 11:47:42 +03:00
1 parent c0e815ae63
commit 0fe2c70848
5 files changed
+463 -42

No files matched your search

+26
View File
@@ -68,6 +68,12 @@ export interface DailyIssueStats {
days: DailyIssueStat[]
}
export interface CalendarIssues {
list: Issue[]
/** The range holds more issues than returned — narrow the filter */
truncated: boolean
}
// CLIENT REQUEST
export async function itemsList(params: {
@@ -77,6 +83,7 @@ export async function itemsList(params: {
status?: string | string[] | null
assigneeId?: string | null
projectId?: string | null
hasDueDate?: boolean
}) {
const query = toQuery({
page: params.page,
@@ -85,10 +92,29 @@ export async function itemsList(params: {
text: params.text,
assigneeId: params.assigneeId,
status: Array.isArray(params.status) ? params.status.join(',') : params.status,
hasDueDate: params.hasDueDate === undefined ? undefined : String(params.hasDueDate),
})
return apiClient.get<{ list: Issue[]; meta: Meta }>(`/api/issues/list${query}`)
}
/** Issues with dueDate in [from, to); from/to are ISO moments with an offset */
export async function itemsCalendar(params: {
from: string
to: string
projectId?: string | null
assigneeId?: string | null
status?: string[] | null
}) {
const query = toQuery({
from: params.from,
to: params.to,
projectId: params.projectId,
assigneeId: params.assigneeId,
status: params.status?.join(','),
})
return apiClient.get<CalendarIssues>(`/api/issues/calendar${query}`)
}
export async function itemGetByKey(ckey: string) {
return apiClient.get<Issue>(`/api/issues/by-key/${ckey}`)
}
+4 -1
View File
@@ -10,7 +10,10 @@ const backTo = computed(() => {
})
function goBack() {
if (backTo.value) router.push({ name: backTo.value })
// vue-router keeps the previous in-app location in history.state.back;
// it's null when the page was opened directly, so fall back to backTo
if (window.history.state?.back) router.back()
else if (backTo.value) router.push({ name: backTo.value })
}
</script>
+11 -1
View File
@@ -6,6 +6,7 @@ import {
type DailyIssueStats,
itemCreate,
itemDailyStat,
itemsCalendar,
itemDelete,
itemGetByKey,
itemsList,
@@ -34,6 +35,7 @@ export const useIssueStore = defineStore('item', () => {
status?: string | string[] | null
assigneeId?: string | null
projectId?: string | null
hasDueDate?: boolean
}) {
try {
const res = await itemsList(params)
@@ -45,6 +47,14 @@ export const useIssueStore = defineStore('item', () => {
}
}
async function calendar(params: { from: string; to: string; projectId?: string | null }) {
try {
return await itemsCalendar(params)
} catch (e) {
error.value = e instanceof Error ? e.message : 'error get calendar issues'
}
}
async function getByKey(ckey: string) {
try {
return await itemGetByKey(ckey)
@@ -110,7 +120,7 @@ export const useIssueStore = defineStore('item', () => {
}
}
return { stats, dailyStats, list, getByKey, create, update, remove, getStat, getDailyStat }
return { stats, dailyStats, list, calendar, getByKey, create, update, remove, getStat, getDailyStat }
})
/** Local YYYY-MM-DD `days` days before today */
-2
View File
@@ -153,8 +153,6 @@ function onClickCard(status?: string) {
<dashboard-count-card label="Overdue" :value="overdueCount" :alert="overdueCount > 0" />
</div>
<v-divider />
<v-row density="compact">
<v-col cols="12" md="6">
<v-card border>
+422 -38
View File
@@ -1,54 +1,438 @@
<script setup lang="ts">
import { ref } from 'vue'
import { computed, ref, watch } from 'vue'
import { storeToRefs } from 'pinia'
import { useRouter } from 'vue-router'
import { watchDebounced } from '@vueuse/core'
import { useIssueStore } from '@/stores/issue.ts'
import { useProjectStore } from '@/stores/project.ts'
import { useStatusStore } from '@/stores/status.ts'
import type { Issue } from '@/api/issue.api.ts'
interface CalendarEvent {
name: string
start: Date | string | number
end?: Date | string | number
color?: string
timed?: boolean
start: string
color: string
issue: Issue
}
const type = ref('month')
const types = ['month', 'week', 'day', '4day']
const mode = ref('stack')
const modes = ['stack', 'column']
const weekday = ref([0, 1, 2, 3, 4, 5, 6])
const weekdays = [
{ title: 'Sun - Sat', value: [0, 1, 2, 3, 4, 5, 6] },
{ title: 'Mon - Sun', value: [1, 2, 3, 4, 5, 6, 0] },
{ title: 'Mon - Fri', value: [1, 2, 3, 4, 5] },
{ title: 'Mon, Wed, Fri', value: [1, 3, 5] },
]
const value = ref('')
const events = ref<CalendarEvent[]>([])
const colors = ['blue', 'indigo', 'deep-purple', 'cyan', 'green', 'orange', 'grey-darken-1']
const names = ['Meeting', 'Holiday', 'PTO', 'Travel', 'Event', 'Birthday', 'Conference', 'Party']
function rnd(a: number, b: number) {
return Math.floor((b - a + 1) * Math.random()) + a
interface CalendarRange {
start: { date: string }
end: { date: string }
}
function getEvents() {
events.value = [
{
name: 'test',
start: new Date('2026-10-09T13:41:45.213Z'),
end: new Date('2026-10-11T13:41:45.213Z'),
color: 'primary',
timed: true,
},
]
const UNSCHEDULED_LIMIT = 20
const DRAG_TYPE = 'application/x-ulitka-issue'
const issueStore = useIssueStore()
const statusStore = useStatusStore()
const { currentProjectId } = storeToRefs(useProjectStore())
const router = useRouter()
const calendarRef = ref<{ prev: () => void; next: () => void }>()
const focus = ref(toLocalDate(new Date()))
const range = ref<CalendarRange>()
const scheduled = ref<Issue[]>([])
const truncated = ref(false)
const loadingScheduled = ref(false)
const unscheduled = ref<Issue[]>([])
const unscheduledPage = ref(1)
const unscheduledTotalPages = ref(0)
const unscheduledTotal = ref(0)
const loadingUnscheduled = ref(false)
const search = ref<string | null>(null)
/** Day picked on the calendar — target for the "+" buttons in the unscheduled list */
const selectedDate = ref<string | null>(null)
const dragOverDate = ref<string | null>(null)
const dragOverPanel = ref(false)
const savingIds = ref<number[]>([])
const title = computed(() => {
const [year, month] = focus.value.split('-').map(Number)
return new Date(year!, month! - 1, 1).toLocaleDateString('en-US', { month: 'long', year: 'numeric' })
})
const events = computed<CalendarEvent[]>(() =>
scheduled.value
.filter((issue) => issue.dueDate)
.map((issue) => ({
name: `${issue.key} ${issue.title}`,
start: toLocalDate(new Date(issue.dueDate!)),
color: statusColor(issue.status),
issue,
})),
)
function statusColor(status: Issue['status']) {
return statusStore.statuses?.find((el) => el.alias === status)?.color ?? 'primary'
}
/** Local YYYY-MM-DD of a moment */
function toLocalDate(date: Date): string {
const pad = (value: number) => String(value).padStart(2, '0')
return `${date.getFullYear()}-${pad(date.getMonth() + 1)}-${pad(date.getDate())}`
}
/** Local midnight of a YYYY-MM-DD day, `shiftDays` days later */
function localMidnight(date: string, shiftDays = 0): Date {
const [year, month, day] = date.split('-').map(Number)
return new Date(year!, month! - 1, day! + shiftDays)
}
function formatDate(date: string) {
return localMidnight(date).toLocaleDateString('ru-RU')
}
let scheduledRequestId = 0
async function loadScheduled() {
if (!range.value) return
// The month view shows whole weeks (Mon–Sun), so request the visible grid, not just the month
const start = localMidnight(range.value.start.date)
start.setDate(start.getDate() - ((start.getDay() + 6) % 7))
const end = localMidnight(range.value.end.date, 1)
end.setDate(end.getDate() + ((8 - end.getDay()) % 7))
const requestId = ++scheduledRequestId
loadingScheduled.value = true
const res = await issueStore.calendar({
from: start.toISOString(),
to: end.toISOString(),
projectId: currentProjectId.value,
})
if (requestId !== scheduledRequestId) return
loadingScheduled.value = false
scheduled.value = res?.list ?? []
truncated.value = res?.truncated ?? false
}
let unscheduledRequestId = 0
async function loadUnscheduled(page = 1) {
const requestId = ++unscheduledRequestId
loadingUnscheduled.value = true
const res = await issueStore.list({
page,
limit: UNSCHEDULED_LIMIT,
text: search.value,
projectId: currentProjectId.value,
hasDueDate: false,
})
if (requestId !== unscheduledRequestId) return
loadingUnscheduled.value = false
unscheduled.value = page === 1 ? res.list : [...unscheduled.value, ...res.list]
unscheduledPage.value = page
unscheduledTotalPages.value = res.meta.totalPages
unscheduledTotal.value = res.meta.totalCount
}
function onRangeChange(value: CalendarRange) {
range.value = value
loadScheduled()
}
watch(currentProjectId, () => {
loadScheduled()
loadUnscheduled()
})
watchDebounced(search, () => loadUnscheduled(), { debounce: 300 })
loadUnscheduled()
async function setDueDate(issue: Issue, date: string | null) {
if (savingIds.value.includes(issue.id)) return
if (date && issue.dueDate && toLocalDate(new Date(issue.dueDate)) === date) return
savingIds.value.push(issue.id)
const updated = await issueStore.update(issue.id, {
dueDate: date ? localMidnight(date).toISOString() : null,
})
savingIds.value = savingIds.value.filter((id) => id !== issue.id)
if (!updated) return
const wasUnscheduled = unscheduled.value.some((el) => el.id === issue.id)
unscheduled.value = unscheduled.value.filter((el) => el.id !== issue.id)
scheduled.value = scheduled.value.filter((el) => el.id !== issue.id)
if (updated.dueDate) {
scheduled.value.push(updated)
if (wasUnscheduled) unscheduledTotal.value--
} else {
unscheduled.value.unshift(updated)
unscheduledTotal.value++
}
}
function findIssue(id: number) {
return unscheduled.value.find((el) => el.id === id) ?? scheduled.value.find((el) => el.id === id)
}
function onDragStart(e: DragEvent, issue: Issue) {
if (!e.dataTransfer) return
e.dataTransfer.effectAllowed = 'move'
e.dataTransfer.setData(DRAG_TYPE, String(issue.id))
e.dataTransfer.setData('text/plain', issue.key)
}
function onDragEnd() {
dragOverDate.value = null
dragOverPanel.value = false
}
function isIssueDrag(e: Event): e is DragEvent {
return e instanceof DragEvent && !!e.dataTransfer?.types.includes(DRAG_TYPE)
}
function onDayDragOver(e: Event, day: { date: string }) {
if (!isIssueDrag(e)) return
e.preventDefault()
dragOverDate.value = day.date
}
function onDayDrop(e: Event, day: { date: string }) {
dragOverDate.value = null
if (!isIssueDrag(e)) return
const issue = findIssue(Number(e.dataTransfer?.getData(DRAG_TYPE)))
if (issue) setDueDate(issue, day.date)
}
function onPanelDragOver(e: DragEvent) {
if (!isIssueDrag(e)) return
e.preventDefault()
dragOverPanel.value = true
}
function onPanelDrop(e: DragEvent) {
dragOverPanel.value = false
const id = Number(e.dataTransfer?.getData(DRAG_TYPE))
// Only calendar events can be dropped back to unschedule them
const issue = scheduled.value.find((el) => el.id === id)
if (issue) setDueDate(issue, null)
}
function onDayClick(_e: Event, day: { date: string }) {
selectedDate.value = selectedDate.value === day.date ? null : day.date
}
function onEventClick(e: Event, scope: { event: Partial<CalendarEvent> }) {
// Don't let the click reach the day cell and toggle the selection
e.stopPropagation()
if (scope.event.issue) router.push(`/issues/${scope.event.issue.key}`)
}
function goToday() {
focus.value = toLocalDate(new Date())
}
</script>
<template>
<div>
<v-sheet height="600">
<v-calendar format="24hr" type="month" first-day-of-week="1" @change="getEvents" :events="events">
<template #day> aaaa </template>
<div class="calendar-layout">
<div class="d-flex flex-column ga-2 calendar-main">
<div class="d-flex align-center ga-2">
<v-btn density="comfortable" icon="mdi-chevron-left" @click="calendarRef?.prev()" />
<v-btn density="comfortable" icon="mdi-chevron-right" @click="calendarRef?.next()" />
<v-btn density="comfortable" @click="goToday">Today</v-btn>
<div class="text-h6 ml-2">{{ title }}</div>
<v-progress-circular v-if="loadingScheduled" indeterminate size="20" width="2" />
<v-spacer />
<v-chip v-if="truncated" color="warning" size="small" prepend-icon="mdi-alert">
Not all issues are shown
</v-chip>
</div>
<v-sheet class="flex-grow-1" border>
<v-calendar
ref="calendarRef"
v-model="focus"
type="month"
:weekdays="[1, 2, 3, 4, 5, 6, 0]"
:events="events"
event-text-color="white"
@change="onRangeChange"
@click:day="onDayClick"
@click:event="onEventClick"
@dragover:day="onDayDragOver"
@drop:day="onDayDrop"
>
<template #day-label="day">
<div class="d-flex justify-center">
<v-btn
icon
size="x-small"
:variant="selectedDate === day.date ? 'flat' : day.present ? 'outlined' : 'text'"
:color="selectedDate === day.date || day.present ? 'primary' : undefined"
@click.stop="onDayClick($event, day)"
>
{{ day.day }}
</v-btn>
</div>
</template>
<template #day="day">
<div
class="day-overlay"
:class="{
'day-overlay--selected': selectedDate === day.date,
'day-overlay--drop': dragOverDate === day.date,
}"
/>
</template>
<template #event="{ event }">
<div
class="pl-1 text-truncate cursor-grab"
:class="{ 'opacity-50': savingIds.includes(event.issue.id) }"
:title="event.name"
draggable="true"
@dragstart.stop="onDragStart($event, event.issue)"
@dragend="onDragEnd"
>
<span
class="font-weight-bold"
:class="{ 'text-decoration-line-through': ['DONE', 'CANCELLED'].includes(event.issue.status) }"
>
{{ event.issue.key }}
</span>
{{ event.issue.title }}
</div>
</template>
</v-calendar>
</v-sheet>
</div>
<v-card
border
class="d-flex flex-column calendar-side"
:class="{ 'calendar-side--drop': dragOverPanel }"
@dragover="onPanelDragOver"
@dragleave.self="dragOverPanel = false"
@drop="onPanelDrop"
>
<v-card-title class="d-flex align-center ga-2 text-subtitle-1">
Unscheduled
<v-chip size="x-small">{{ unscheduledTotal }}</v-chip>
</v-card-title>
<div class="px-4 pb-2 d-flex flex-column ga-2">
<v-text-field v-model="search" placeholder="Search" prepend-inner-icon="mdi-magnify" clearable />
<div class="text-caption text-medium-emphasis d-flex align-center ga-1">
<template v-if="selectedDate">
<span>
Selected: <b>{{ formatDate(selectedDate) }}</b> — press + to schedule
</span>
<v-btn density="compact" size="x-small" icon="mdi-close" variant="text" @click="selectedDate = null" />
</template>
<span v-else>Drag an issue onto a day, or pick a day and press +</span>
</div>
</div>
<v-divider />
<div class="flex-grow-1 overflow-y-auto">
<v-list class="pa-0">
<v-list-item
v-for="issue in unscheduled"
:key="issue.id"
class="cursor-grab"
:class="{ 'opacity-50': savingIds.includes(issue.id) }"
draggable="true"
@dragstart="onDragStart($event, issue)"
@dragend="onDragEnd"
>
<template #prepend>
<v-icon icon="mdi-drag-vertical" size="small" class="mr-1" />
</template>
<v-list-item-title>
<router-link :to="`/issues/${issue.key}`" class="text-link font-weight-bold mr-1" draggable="false">
{{ issue.key }}
</router-link>
<span :title="issue.title">{{ issue.title }}</span>
</v-list-item-title>
<template #append>
<v-btn
density="compact"
icon="mdi-plus"
variant="text"
:disabled="!selectedDate"
:loading="savingIds.includes(issue.id)"
:title="selectedDate ? `Schedule for ${formatDate(selectedDate)}` : 'Pick a day on the calendar first'"
@click="selectedDate && setDueDate(issue, selectedDate)"
/>
</template>
</v-list-item>
</v-list>
<div v-if="!loadingUnscheduled && !unscheduled.length" class="pa-4 text-center text-medium-emphasis">
No unscheduled issues
</div>
<div v-if="unscheduledPage < unscheduledTotalPages || loadingUnscheduled" class="d-flex justify-center pa-2">
<v-btn density="compact" :loading="loadingUnscheduled" @click="loadUnscheduled(unscheduledPage + 1)">
Load more
</v-btn>
</div>
</div>
<div v-if="dragOverPanel" class="text-caption text-center pa-2 text-primary">Drop here to remove the date</div>
</v-card>
</div>
</template>
<style scoped lang="scss"></style>
<style scoped lang="scss">
.calendar-layout {
display: grid;
grid-template-columns: minmax(0, 1fr) 22rem;
gap: 16px;
height: calc(100vh - 140px);
min-height: 600px;
}
.calendar-main {
min-width: 0;
}
.calendar-side {
min-height: 0;
transition: border-color 0.15s;
&--drop {
border-color: rgb(var(--v-theme-primary)) !important;
}
}
.day-overlay {
position: absolute;
inset: 0;
pointer-events: none;
&--selected {
background: rgba(var(--v-theme-primary), 0.08);
}
&--drop {
background: rgba(var(--v-theme-primary), 0.18);
outline: 2px dashed rgb(var(--v-theme-primary));
outline-offset: -2px;
}
}
.cursor-grab {
cursor: grab;
}
@media (max-width: 960px) {
.calendar-layout {
grid-template-columns: 1fr;
height: auto;
}
.calendar-main {
min-height: 600px;
}
.calendar-side {
max-height: 60vh;
}
}
</style>