ac90548823
- Add 'cancelled' status to TaskStatus type, StatusBadge, TaskCard, TaskEditorView, TaskViewerView, TasksListView - Add RecurrenceEditor component (none / interval / calendar rules) - TaskEditorView: wire RecurrenceEditor, show started_at/completed_at timestamps read-only, include recurrence_rule in save payload - TaskViewerView: show recurrence summary, timestamps in meta row - tasks.ts: statusFilter/priorityFilter as arrays, add recurrence_rule to updateTask and createTask payloads Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
172 lines
4.7 KiB
Vue
172 lines
4.7 KiB
Vue
<script setup lang="ts">
|
|
import { ref, watch, computed } from "vue";
|
|
|
|
const props = defineProps<{
|
|
modelValue: Record<string, unknown> | null;
|
|
}>();
|
|
|
|
const emit = defineEmits<{
|
|
(e: "update:modelValue", val: Record<string, unknown> | null): void;
|
|
}>();
|
|
|
|
type RecurrenceType = "none" | "interval" | "calendar";
|
|
type IntervalUnit = "day" | "week" | "month" | "year";
|
|
type CalendarUnit = "month" | "year";
|
|
|
|
const rType = ref<RecurrenceType>("none");
|
|
const intervalEvery = ref(1);
|
|
const intervalUnit = ref<IntervalUnit>("week");
|
|
const calendarUnit = ref<CalendarUnit>("month");
|
|
const calendarDay = ref(1);
|
|
const calendarMonth = ref(1);
|
|
|
|
function ruleFromState(): Record<string, unknown> | null {
|
|
if (rType.value === "none") return null;
|
|
if (rType.value === "interval") {
|
|
return { type: "interval", every: intervalEvery.value, unit: intervalUnit.value };
|
|
}
|
|
// calendar
|
|
const rule: Record<string, unknown> = {
|
|
type: "calendar",
|
|
unit: calendarUnit.value,
|
|
day_of_month: calendarDay.value,
|
|
};
|
|
if (calendarUnit.value === "year") {
|
|
rule.month = calendarMonth.value;
|
|
}
|
|
return rule;
|
|
}
|
|
|
|
function loadFromRule(rule: Record<string, unknown> | null) {
|
|
if (!rule) {
|
|
rType.value = "none";
|
|
return;
|
|
}
|
|
const t = rule.type as string;
|
|
if (t === "interval") {
|
|
rType.value = "interval";
|
|
intervalEvery.value = (rule.every as number) ?? 1;
|
|
intervalUnit.value = (rule.unit as IntervalUnit) ?? "week";
|
|
} else if (t === "calendar") {
|
|
rType.value = "calendar";
|
|
calendarUnit.value = (rule.unit as CalendarUnit) ?? "month";
|
|
calendarDay.value = (rule.day_of_month as number) ?? 1;
|
|
calendarMonth.value = (rule.month as number) ?? 1;
|
|
} else {
|
|
rType.value = "none";
|
|
}
|
|
}
|
|
|
|
// Load initial value
|
|
loadFromRule(props.modelValue);
|
|
|
|
// Watch for external changes (e.g., task loaded)
|
|
watch(() => props.modelValue, (val) => {
|
|
loadFromRule(val);
|
|
}, { deep: true });
|
|
|
|
// Emit on any state change
|
|
function onChange() {
|
|
emit("update:modelValue", ruleFromState());
|
|
}
|
|
|
|
const monthNames = [
|
|
"January","February","March","April","May","June",
|
|
"July","August","September","October","November","December",
|
|
];
|
|
|
|
const calendarDayMax = computed(() =>
|
|
calendarUnit.value === "month" ? 28 : 28
|
|
);
|
|
</script>
|
|
|
|
<template>
|
|
<div class="rec-editor">
|
|
<select v-model="rType" class="sb-select" @change="onChange">
|
|
<option value="none">No recurrence</option>
|
|
<option value="interval">Every N days/weeks/months</option>
|
|
<option value="calendar">On a calendar date</option>
|
|
</select>
|
|
|
|
<div v-if="rType === 'interval'" class="rec-row">
|
|
<span class="rec-label">Every</span>
|
|
<input
|
|
v-model.number="intervalEvery"
|
|
type="number"
|
|
min="1"
|
|
max="365"
|
|
class="rec-num-input"
|
|
@input="onChange"
|
|
/>
|
|
<select v-model="intervalUnit" class="sb-select rec-unit" @change="onChange">
|
|
<option value="day">day(s)</option>
|
|
<option value="week">week(s)</option>
|
|
<option value="month">month(s)</option>
|
|
<option value="year">year(s)</option>
|
|
</select>
|
|
</div>
|
|
|
|
<div v-if="rType === 'calendar'" class="rec-row rec-col">
|
|
<div class="rec-row">
|
|
<span class="rec-label">Unit</span>
|
|
<select v-model="calendarUnit" class="sb-select" @change="onChange">
|
|
<option value="month">Monthly</option>
|
|
<option value="year">Yearly</option>
|
|
</select>
|
|
</div>
|
|
<div class="rec-row">
|
|
<span class="rec-label">Day</span>
|
|
<input
|
|
v-model.number="calendarDay"
|
|
type="number"
|
|
min="1"
|
|
:max="calendarDayMax"
|
|
class="rec-num-input"
|
|
@input="onChange"
|
|
/>
|
|
</div>
|
|
<div v-if="calendarUnit === 'year'" class="rec-row">
|
|
<span class="rec-label">Month</span>
|
|
<select v-model.number="calendarMonth" class="sb-select" @change="onChange">
|
|
<option v-for="(name, i) in monthNames" :key="i + 1" :value="i + 1">{{ name }}</option>
|
|
</select>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<style scoped>
|
|
.rec-editor {
|
|
display: flex;
|
|
flex-direction: column;
|
|
gap: 0.4rem;
|
|
}
|
|
.rec-row {
|
|
display: flex;
|
|
align-items: center;
|
|
gap: 0.4rem;
|
|
flex-wrap: wrap;
|
|
}
|
|
.rec-col {
|
|
flex-direction: column;
|
|
align-items: flex-start;
|
|
}
|
|
.rec-label {
|
|
font-size: 0.78rem;
|
|
color: var(--color-text-muted);
|
|
min-width: 2.5rem;
|
|
}
|
|
.rec-num-input {
|
|
width: 4rem;
|
|
padding: 0.25rem 0.4rem;
|
|
border: 1px solid var(--color-input-border, var(--color-border));
|
|
border-radius: var(--radius-sm);
|
|
background: var(--color-bg);
|
|
color: var(--color-text);
|
|
font-size: 0.85rem;
|
|
font-family: inherit;
|
|
}
|
|
.rec-num-input:focus { outline: none; border-color: var(--color-primary); }
|
|
.rec-unit { min-width: 6rem; }
|
|
</style>
|