feat(dayplan): a day can be added with the next date, which extends the trip by one

On a trip with dates the reorder dialog and the days sheet on a phone now offer two ways to add a day: a day without a date at the end as before, or the calendar day after the last date, named on its button, which extends the trip by one day. The new day goes right behind the last dated day, so the days without a date move one place back with their plans, and no existing day or booking changes its date.

The server does it in one transaction behind a new dated flag on the day create contract, used by REST, the create_day tool and the plugin RPC alike. It moves the road trip boundaries of the days behind the new one, and announces the day with day:reordered and the grown trip with trip:updated; an insert at a position announces its trip now as well. The next date and the day limit come from one shared rule, so the button never promises a date the server would not give.

On the client the add is online only and runs one at a time, so a double click cannot grow the trip by two days. A wiring test that picked the stay question by its position now finds it by its handler, since the delete question sits in front of it.
This commit is contained in:
Maurice
2026-09-23 19:16:04 +02:00
parent 2f6f921aef
commit b56f335978
64 changed files with 1745 additions and 113 deletions
+31 -1
View File
@@ -1,4 +1,9 @@
import { dayCreateRequestSchema, dayNoteCreateRequestSchema, dayNoteUpdateRequestSchema } from './day.schema';
import {
DAY_CREATE_DATED_CONFLICT,
dayCreateRequestSchema,
dayNoteCreateRequestSchema,
dayNoteUpdateRequestSchema,
} from './day.schema';
import { describe, it, expect } from 'vitest';
@@ -7,6 +12,31 @@ describe('dayCreateRequestSchema', () => {
expect(dayCreateRequestSchema.safeParse({}).success).toBe(true);
expect(dayCreateRequestSchema.safeParse({ date: '2026-07-01', notes: 'n' }).success).toBe(true);
});
it('takes dated on its own or with notes', () => {
expect(dayCreateRequestSchema.safeParse({ dated: true }).success).toBe(true);
expect(dayCreateRequestSchema.safeParse({ dated: true, notes: 'Late checkout' }).success).toBe(true);
// An explicit false is a plain append, so it combines with anything.
expect(dayCreateRequestSchema.safeParse({ dated: false, position: 2, date: '2026-07-01' }).success).toBe(true);
});
it('refuses dated next to a position or a date, on the dated key', () => {
for (const body of [
{ dated: true, position: 2 },
{ dated: true, date: '2026-07-01' },
]) {
const parsed = dayCreateRequestSchema.safeParse(body);
expect(parsed.success).toBe(false);
expect(parsed.error?.issues[0]).toMatchObject({ message: DAY_CREATE_DATED_CONFLICT, path: ['dated'] });
}
});
it('keeps its fields reachable for the MCP tool, which builds its input from them', () => {
expect(dayCreateRequestSchema.shape.date.safeParse('2026-07-01').success).toBe(true);
expect(dayCreateRequestSchema.shape.notes.safeParse(undefined).success).toBe(true);
expect(dayCreateRequestSchema.shape.position.safeParse(0).success).toBe(false);
expect(dayCreateRequestSchema.shape.dated.safeParse(true).success).toBe(true);
});
});
describe('dayNoteCreateRequestSchema', () => {
+21 -6
View File
@@ -52,12 +52,27 @@ export const daySchema = z.object({
});
export type Day = z.infer<typeof daySchema>;
export const dayCreateRequestSchema = z.object({
date: z.string().optional(),
notes: z.string().optional(),
// 1-based slot to insert a new empty day at (omit to append at the end).
position: z.number().int().positive().optional(),
});
/** The refusal for `dated` next to `date` or `position`, the same on REST, MCP and the plugin RPC. */
export const DAY_CREATE_DATED_CONFLICT = 'dated cannot be combined with date or position';
/**
* A new day: appended at the end, slotted in at `position`, or with `dated` the
* calendar day after the trip's last date, which extends the trip by one day.
* `dated` picks its own date and place (behind the last dated day, in front of
* the days without a date), so neither can be given next to it.
*/
export const dayCreateRequestSchema = z
.object({
date: z.string().optional(),
notes: z.string().optional(),
// 1-based slot to insert a new empty day at (omit to append at the end).
position: z.number().int().positive().optional(),
dated: z.boolean().optional(),
})
.refine((body) => !body.dated || (body.position === undefined && body.date === undefined), {
message: DAY_CREATE_DATED_CONFLICT,
path: ['dated'],
});
export type DayCreateRequest = z.infer<typeof dayCreateRequestSchema>;
/** Reorder whole days: the desired full sequence of this trip's day ids. */
+5
View File
@@ -112,5 +112,10 @@ const dayplan: TranslationStrings = {
'dayplan.daysOffline': 'تغيير الأيام يتطلب اتصالًا',
'dayplan.deleteDaySuccess': 'تم حذف اليوم',
'dayplan.deleteDayError': 'تعذّر حذف اليوم',
'dayplan.addUndatedDay': 'إضافة يوم بلا تاريخ',
'dayplan.addUndatedDayHint': 'يضيف يومًا بلا تاريخ في النهاية. تبقى تواريخ الرحلة كما هي.',
'dayplan.addDatedDay': 'إضافة {date}',
'dayplan.addDatedDayHint': 'يمدّد الرحلة يومًا واحدًا حتى {date}.',
'dayplan.tripExtended': 'تمت إضافة اليوم. تنتهي الرحلة الآن في {date}.',
};
export default dayplan;
+5
View File
@@ -113,5 +113,10 @@ const dayplan: TranslationStrings = {
'dayplan.daysOffline': 'Alterar os dias exige conexão',
'dayplan.deleteDaySuccess': 'Dia excluído',
'dayplan.deleteDayError': 'Falha ao excluir o dia',
'dayplan.addUndatedDay': 'Adicionar dia sem data',
'dayplan.addUndatedDayHint': 'Adiciona um dia sem data no final. As datas da viagem continuam as mesmas.',
'dayplan.addDatedDay': 'Adicionar {date}',
'dayplan.addDatedDayHint': 'Estende a viagem em um dia, até {date}.',
'dayplan.tripExtended': 'Dia adicionado. A viagem agora termina em {date}.',
};
export default dayplan;
+5
View File
@@ -113,5 +113,10 @@ const dayplan: TranslationStrings = {
'dayplan.daysOffline': 'Per canviar els dies cal connexió',
'dayplan.deleteDaySuccess': 'Dia eliminat',
'dayplan.deleteDayError': "No s'ha pogut eliminar el dia",
'dayplan.addUndatedDay': 'Afegeix un dia sense data',
'dayplan.addUndatedDayHint': 'Afegeix un dia sense data al final. Les dates del viatge no canvien.',
'dayplan.addDatedDay': 'Afegeix {date}',
'dayplan.addDatedDayHint': 'Allarga el viatge un dia, fins al {date}.',
'dayplan.tripExtended': "S'ha afegit el dia. Ara el viatge acaba el {date}.",
};
export default dayplan;
+5
View File
@@ -114,5 +114,10 @@ const dayplan: TranslationStrings = {
'dayplan.daysOffline': 'Změna dnů vyžaduje připojení',
'dayplan.deleteDaySuccess': 'Den smazán',
'dayplan.deleteDayError': 'Den se nepodařilo smazat',
'dayplan.addUndatedDay': 'Přidat den bez data',
'dayplan.addUndatedDayHint': 'Přidá na konec den bez data. Termín cesty zůstane stejný.',
'dayplan.addDatedDay': 'Přidat {date}',
'dayplan.addDatedDayHint': 'Prodlouží cestu o jeden den, do {date}.',
'dayplan.tripExtended': 'Den přidán. Cesta teď končí {date}.',
};
export default dayplan;
+5
View File
@@ -114,5 +114,10 @@ const dayplan: TranslationStrings = {
'dayplan.daysOffline': 'Tage ändern geht nur mit Verbindung',
'dayplan.deleteDaySuccess': 'Tag gelöscht',
'dayplan.deleteDayError': 'Tag konnte nicht gelöscht werden',
'dayplan.addUndatedDay': 'Tag ohne Datum hinzufügen',
'dayplan.addUndatedDayHint': 'Hängt hinten einen Tag ohne Datum an. Die Reisedaten bleiben, wie sie sind.',
'dayplan.addDatedDay': '{date} hinzufügen',
'dayplan.addDatedDayHint': 'Verlängert die Reise um einen Tag, bis {date}.',
'dayplan.tripExtended': 'Tag hinzugefügt. Die Reise endet jetzt am {date}.',
};
export default dayplan;
+5
View File
@@ -113,5 +113,10 @@ const dayplan: TranslationStrings = {
'dayplan.daysOffline': 'Changing days needs a connection',
'dayplan.deleteDaySuccess': 'Day deleted',
'dayplan.deleteDayError': 'Failed to delete day',
'dayplan.addUndatedDay': 'Add day without date',
'dayplan.addUndatedDayHint': 'Adds a day without a date at the end. The trip dates stay as they are.',
'dayplan.addDatedDay': 'Add {date}',
'dayplan.addDatedDayHint': 'Extends the trip by one day, until {date}.',
'dayplan.tripExtended': 'Day added. The trip now ends on {date}.',
};
export default dayplan;
+5
View File
@@ -113,5 +113,10 @@ const dayplan: TranslationStrings = {
'dayplan.daysOffline': 'Cambiar los días requiere conexión',
'dayplan.deleteDaySuccess': 'Día eliminado',
'dayplan.deleteDayError': 'No se pudo eliminar el día',
'dayplan.addUndatedDay': 'Añadir día sin fecha',
'dayplan.addUndatedDayHint': 'Añade un día sin fecha al final. Las fechas del viaje no cambian.',
'dayplan.addDatedDay': 'Añadir {date}',
'dayplan.addDatedDayHint': 'Alarga el viaje un día, hasta el {date}.',
'dayplan.tripExtended': 'Día añadido. El viaje termina ahora el {date}.',
};
export default dayplan;
+5
View File
@@ -113,5 +113,10 @@ const dayplan: TranslationStrings = {
'dayplan.daysOffline': 'Modifier les jours nécessite une connexion',
'dayplan.deleteDaySuccess': 'Jour supprimé',
'dayplan.deleteDayError': 'Échec de la suppression du jour',
'dayplan.addUndatedDay': 'Ajouter un jour sans date',
'dayplan.addUndatedDayHint': 'Ajoute un jour sans date à la fin. Les dates du voyage ne changent pas.',
'dayplan.addDatedDay': 'Ajouter {date}',
'dayplan.addDatedDayHint': "Prolonge le voyage d'un jour, jusqu'au {date}.",
'dayplan.tripExtended': 'Jour ajouté. Le voyage se termine désormais le {date}.',
};
export default dayplan;
+6
View File
@@ -114,5 +114,11 @@ const dayplan: TranslationStrings = {
'dayplan.daysOffline': 'Η αλλαγή ημερών απαιτεί σύνδεση',
'dayplan.deleteDaySuccess': 'Η ημέρα διαγράφηκε',
'dayplan.deleteDayError': 'Η διαγραφή της ημέρας απέτυχε',
'dayplan.addUndatedDay': 'Προσθήκη ημέρας χωρίς ημερομηνία',
'dayplan.addUndatedDayHint':
'Προσθέτει στο τέλος μια ημέρα χωρίς ημερομηνία. Οι ημερομηνίες του ταξιδιού μένουν ίδιες.',
'dayplan.addDatedDay': 'Προσθήκη {date}',
'dayplan.addDatedDayHint': 'Επεκτείνει το ταξίδι κατά μία ημέρα, έως {date}.',
'dayplan.tripExtended': 'Η ημέρα προστέθηκε. Το ταξίδι τελειώνει πλέον στις {date}.',
};
export default dayplan;
+5
View File
@@ -114,5 +114,10 @@ const dayplan: TranslationStrings = {
'dayplan.daysOffline': 'A napok módosításához kapcsolat szükséges',
'dayplan.deleteDaySuccess': 'Nap törölve',
'dayplan.deleteDayError': 'Nem sikerült törölni a napot',
'dayplan.addUndatedDay': 'Nap hozzáadása dátum nélkül',
'dayplan.addUndatedDayHint': 'Egy dátum nélküli napot ad a végéhez. Az utazás dátumai nem változnak.',
'dayplan.addDatedDay': '{date} hozzáadása',
'dayplan.addDatedDayHint': 'Egy nappal meghosszabbítja az utazást. Új utolsó nap: {date}.',
'dayplan.tripExtended': 'Nap hozzáadva. Az utazás utolsó napja most: {date}.',
};
export default dayplan;
+5
View File
@@ -113,5 +113,10 @@ const dayplan: TranslationStrings = {
'dayplan.daysOffline': 'Mengubah hari memerlukan koneksi',
'dayplan.deleteDaySuccess': 'Hari dihapus',
'dayplan.deleteDayError': 'Gagal menghapus hari',
'dayplan.addUndatedDay': 'Tambah hari tanpa tanggal',
'dayplan.addUndatedDayHint': 'Menambahkan hari tanpa tanggal di akhir. Tanggal perjalanan tetap sama.',
'dayplan.addDatedDay': 'Tambah {date}',
'dayplan.addDatedDayHint': 'Memperpanjang perjalanan satu hari, hingga {date}.',
'dayplan.tripExtended': 'Hari ditambahkan. Perjalanan kini berakhir pada {date}.',
};
export default dayplan;
+5
View File
@@ -115,5 +115,10 @@ const dayplan: TranslationStrings = {
'dayplan.daysOffline': 'Per modificare i giorni serve una connessione',
'dayplan.deleteDaySuccess': 'Giorno eliminato',
'dayplan.deleteDayError': 'Eliminazione del giorno non riuscita',
'dayplan.addUndatedDay': 'Aggiungi giorno senza data',
'dayplan.addUndatedDayHint': 'Aggiunge un giorno senza data alla fine. Le date del viaggio restano invariate.',
'dayplan.addDatedDay': 'Aggiungi {date}',
'dayplan.addDatedDayHint': 'Allunga il viaggio di un giorno, fino a {date}.',
'dayplan.tripExtended': 'Giorno aggiunto. Il viaggio ora finisce il {date}.',
};
export default dayplan;
+5
View File
@@ -113,5 +113,10 @@ const dayplan: TranslationStrings = {
'dayplan.daysOffline': '日の変更には接続が必要です',
'dayplan.deleteDaySuccess': '日を削除しました',
'dayplan.deleteDayError': '日の削除に失敗しました',
'dayplan.addUndatedDay': '日付なしの日を追加',
'dayplan.addUndatedDayHint': '日付のない日を最後に追加します。旅行の日程は変わりません。',
'dayplan.addDatedDay': '{date}を追加',
'dayplan.addDatedDayHint': '旅行を1日延長し、{date}までにします。',
'dayplan.tripExtended': '日を追加しました。旅行の最終日は{date}になりました。',
};
export default dayplan;
+5
View File
@@ -113,5 +113,10 @@ const dayplan: TranslationStrings = {
'dayplan.daysOffline': '날짜를 변경하려면 연결이 필요합니다',
'dayplan.deleteDaySuccess': '날짜가 삭제되었습니다',
'dayplan.deleteDayError': '날짜를 삭제하지 못했습니다',
'dayplan.addUndatedDay': '날짜 없는 날 추가',
'dayplan.addUndatedDayHint': '날짜 없는 날을 마지막에 추가합니다. 여행 날짜는 그대로입니다.',
'dayplan.addDatedDay': '{date} 추가',
'dayplan.addDatedDayHint': '여행을 하루 늘려 {date}까지로 만듭니다.',
'dayplan.tripExtended': '날짜를 추가했습니다. 이제 여행은 {date}에 끝납니다.',
};
export default dayplan;
+5
View File
@@ -113,5 +113,10 @@ const dayplan: TranslationStrings = {
'dayplan.daysOffline': 'Dagen wijzigen vraagt een verbinding',
'dayplan.deleteDaySuccess': 'Dag verwijderd',
'dayplan.deleteDayError': 'Dag verwijderen mislukt',
'dayplan.addUndatedDay': 'Dag zonder datum toevoegen',
'dayplan.addUndatedDayHint': 'Voegt aan het eind een dag zonder datum toe. De reisdata blijven zoals ze zijn.',
'dayplan.addDatedDay': '{date} toevoegen',
'dayplan.addDatedDayHint': 'Verlengt de reis met één dag, tot {date}.',
'dayplan.tripExtended': 'Dag toegevoegd. De reis eindigt nu op {date}.',
};
export default dayplan;
+5
View File
@@ -115,5 +115,10 @@ const dayplan: TranslationStrings = {
'dayplan.daysOffline': 'Zmiana dni wymaga połączenia',
'dayplan.deleteDaySuccess': 'Dzień usunięty',
'dayplan.deleteDayError': 'Nie udało się usunąć dnia',
'dayplan.addUndatedDay': 'Dodaj dzień bez daty',
'dayplan.addUndatedDayHint': 'Dodaje na końcu dzień bez daty. Daty podróży się nie zmieniają.',
'dayplan.addDatedDay': 'Dodaj {date}',
'dayplan.addDatedDayHint': 'Wydłuża podróż o jeden dzień, do {date}.',
'dayplan.tripExtended': 'Dodano dzień. Podróż kończy się teraz {date}.',
};
export default dayplan;
+5
View File
@@ -114,5 +114,10 @@ const dayplan: TranslationStrings = {
'dayplan.daysOffline': 'Для изменения дней нужно подключение',
'dayplan.deleteDaySuccess': 'День удалён',
'dayplan.deleteDayError': 'Не удалось удалить день',
'dayplan.addUndatedDay': 'Добавить день без даты',
'dayplan.addUndatedDayHint': 'Добавляет в конец день без даты. Даты поездки не меняются.',
'dayplan.addDatedDay': 'Добавить {date}',
'dayplan.addDatedDayHint': 'Продлевает поездку на один день, до {date}.',
'dayplan.tripExtended': 'День добавлен. Теперь поездка заканчивается {date}.',
};
export default dayplan;
+5
View File
@@ -114,5 +114,10 @@ const dayplan: TranslationStrings = {
'dayplan.daysOffline': 'Att ändra dagar kräver en anslutning',
'dayplan.deleteDaySuccess': 'Dagen togs bort',
'dayplan.deleteDayError': 'Kunde inte ta bort dagen',
'dayplan.addUndatedDay': 'Lägg till dag utan datum',
'dayplan.addUndatedDayHint': 'Lägger till en dag utan datum i slutet. Resans datum förblir desamma.',
'dayplan.addDatedDay': 'Lägg till {date}',
'dayplan.addDatedDayHint': 'Förlänger resan med en dag, till {date}.',
'dayplan.tripExtended': 'Dag tillagd. Resan slutar nu {date}.',
};
export default dayplan;
+5
View File
@@ -113,5 +113,10 @@ const dayplan: TranslationStrings = {
'dayplan.daysOffline': 'Günleri değiştirmek için bağlantı gerekir',
'dayplan.deleteDaySuccess': 'Gün silindi',
'dayplan.deleteDayError': 'Gün silinemedi',
'dayplan.addUndatedDay': 'Tarihsiz gün ekle',
'dayplan.addUndatedDayHint': 'Sona tarihsiz bir gün ekler. Seyahat tarihleri aynı kalır.',
'dayplan.addDatedDay': '{date} ekle',
'dayplan.addDatedDayHint': 'Seyahati bir gün uzatır. Yeni son gün: {date}.',
'dayplan.tripExtended': 'Gün eklendi. Seyahatin yeni son günü: {date}.',
};
export default dayplan;
+5
View File
@@ -114,5 +114,10 @@ const dayplan: TranslationStrings = {
'dayplan.daysOffline': 'Для зміни днів потрібне з’єднання',
'dayplan.deleteDaySuccess': 'День видалено',
'dayplan.deleteDayError': 'Не вдалося видалити день',
'dayplan.addUndatedDay': 'Додати день без дати',
'dayplan.addUndatedDayHint': 'Додає в кінець день без дати. Дати подорожі не змінюються.',
'dayplan.addDatedDay': 'Додати {date}',
'dayplan.addDatedDayHint': 'Подовжує подорож на один день, до {date}.',
'dayplan.tripExtended': 'День додано. Тепер подорож закінчується {date}.',
};
export default dayplan;
+5
View File
@@ -113,5 +113,10 @@ const dayplan: TranslationStrings = {
'dayplan.daysOffline': 'Cần có kết nối để thay đổi ngày',
'dayplan.deleteDaySuccess': 'Đã xóa ngày',
'dayplan.deleteDayError': 'Không thể xóa ngày',
'dayplan.addUndatedDay': 'Thêm ngày không có ngày tháng',
'dayplan.addUndatedDayHint': 'Thêm một ngày không có ngày tháng vào cuối. Ngày của chuyến đi giữ nguyên.',
'dayplan.addDatedDay': 'Thêm {date}',
'dayplan.addDatedDayHint': 'Kéo dài chuyến đi thêm một ngày, đến {date}.',
'dayplan.tripExtended': 'Đã thêm ngày. Chuyến đi giờ kết thúc vào {date}.',
};
export default dayplan;
+5
View File
@@ -111,5 +111,10 @@ const dayplan: TranslationStrings = {
'dayplan.daysOffline': '變更日期需要網路連線',
'dayplan.deleteDaySuccess': '已刪除這一天',
'dayplan.deleteDayError': '刪除這一天失敗',
'dayplan.addUndatedDay': '新增無日期的一天',
'dayplan.addUndatedDayHint': '在最後新增一天沒有日期的行程。旅程日期不變。',
'dayplan.addDatedDay': '新增 {date}',
'dayplan.addDatedDayHint': '旅程延長一天,到 {date} 為止。',
'dayplan.tripExtended': '已新增一天。旅程現在於 {date} 結束。',
};
export default dayplan;
+5
View File
@@ -111,5 +111,10 @@ const dayplan: TranslationStrings = {
'dayplan.daysOffline': '更改日期需要网络连接',
'dayplan.deleteDaySuccess': '已删除这一天',
'dayplan.deleteDayError': '删除这一天失败',
'dayplan.addUndatedDay': '添加无日期的一天',
'dayplan.addUndatedDayHint': '在末尾添加一天没有日期的行程。旅行日期不变。',
'dayplan.addDatedDay': '添加 {date}',
'dayplan.addDatedDayHint': '旅行延长一天,到 {date} 为止。',
'dayplan.tripExtended': '已添加一天。旅行现在于 {date} 结束。',
};
export default dayplan;
+1
View File
@@ -48,6 +48,7 @@ export * from './place/track-colors';
export * from './collection/collection.schema';
export * from './collection/collection-file.schema';
export * from './trip/trip.schema';
export * from './trip/day-grid';
export * from './trip-invite/trip-invite.schema';
export * from './collab/collab.schema';
export * from './file/file.schema';
+70
View File
@@ -0,0 +1,70 @@
import { addIsoDays, nextTripDate, planDatedAppend } from './day-grid';
import { MAX_TRIP_DAYS } from './trip.schema';
import { describe, expect, it } from 'vitest';
describe('addIsoDays', () => {
it('steps over the ends of months and years, and back', () => {
expect(addIsoDays('2026-01-31', 1)).toBe('2026-02-01');
expect(addIsoDays('2026-12-31', 1)).toBe('2027-01-01');
expect(addIsoDays('2027-01-01', -1)).toBe('2026-12-31');
expect(addIsoDays('2026-06-07', 0)).toBe('2026-06-07');
});
it('knows the leap day, and a year without one', () => {
expect(addIsoDays('2028-02-28', 1)).toBe('2028-02-29');
expect(addIsoDays('2028-02-29', 1)).toBe('2028-03-01');
expect(addIsoDays('2027-02-28', 1)).toBe('2027-03-01');
});
it('counts calendar days across a daylight saving change', () => {
// Europe moves its clocks on these Sundays; a local-time step would slip a day.
expect(addIsoDays('2026-03-28', 2)).toBe('2026-03-30');
expect(addIsoDays('2026-10-24', 2)).toBe('2026-10-26');
});
});
describe('nextTripDate', () => {
it('is the day after the end date when no day lies beyond it', () => {
expect(nextTripDate('2026-10-12', ['2026-10-10', '2026-10-11', '2026-10-12', null])).toBe('2026-10-13');
expect(nextTripDate('2026-10-12', [])).toBe('2026-10-13');
});
it('is the day after the latest day date when that lies past the end date', () => {
expect(nextTripDate('2026-10-12', ['2026-10-12', '2026-10-15', undefined, '2026-10-14'])).toBe('2026-10-16');
});
it('is null for a trip without an end date', () => {
expect(nextTripDate(null, ['2026-10-12'])).toBeNull();
expect(nextTripDate(undefined, [])).toBeNull();
expect(nextTripDate('', [])).toBeNull();
});
});
describe('planDatedAppend', () => {
it('gives the next date and lets the trip grow to it', () => {
expect(planDatedAppend({ start_date: '2026-10-10', end_date: '2026-10-12' }, ['2026-10-12'])).toEqual({
date: '2026-10-13',
fits: true,
});
});
it('refuses nothing up to the day limit and the day past it', () => {
const start = '2026-01-01';
const lastAllowed = addIsoDays(start, MAX_TRIP_DAYS - 1);
expect(planDatedAppend({ start_date: start, end_date: addIsoDays(lastAllowed, -1) }, [])).toEqual({
date: lastAllowed,
fits: true,
});
expect(planDatedAppend({ start_date: start, end_date: lastAllowed }, [])).toEqual({
date: addIsoDays(lastAllowed, 1),
fits: false,
});
});
it('has nothing to plan for a trip missing either date', () => {
expect(planDatedAppend({ start_date: null, end_date: '2026-10-12' }, [])).toBeNull();
expect(planDatedAppend({ start_date: '2026-10-10', end_date: null }, [])).toBeNull();
expect(planDatedAppend({}, ['2026-10-10'])).toBeNull();
});
});
+67
View File
@@ -0,0 +1,67 @@
import { MAX_TRIP_DAYS, tripSpanDays } from './trip.schema';
/**
* Pure rules for the day rows of a dated trip, for both sides of the wire. The
* server writes by them and the planner shows by them, so a date the planner
* promises before a write is the date the write stores.
*/
const MS_PER_DAY = 86_400_000;
/**
* Add `n` calendar days to a YYYY-MM-DD date, entirely in UTC.
*
* Never through a local-time Date: `new Date('2026-06-07T00:00:00')` is local
* midnight, and the round trip through toISOString() lands on the day before
* wherever the clock sits east of Greenwich.
*/
export function addIsoDays(date: string, n: number): string {
const [y = NaN, m = NaN, d = NaN] = date.split('-').map(Number);
const next = new Date(Date.UTC(y, m - 1, d) + n * MS_PER_DAY);
const yyyy = next.getUTCFullYear();
const mm = String(next.getUTCMonth() + 1).padStart(2, '0');
const dd = String(next.getUTCDate()).padStart(2, '0');
return `${yyyy}-${mm}-${dd}`;
}
/** The day after the later of `endDate` and the latest date a day carries. ISO dates compare as strings. */
function dayAfterLatest(endDate: string, dayDates: readonly (string | null | undefined)[]): string {
const latest = dayDates.reduce<string>((last, date) => (date && date > last ? date : last), endDate);
return addIsoDays(latest, 1);
}
/**
* The date a day added "with a date" gets: the day after the trip's end date,
* or after the latest date a day carries when one lies further out. Null for a
* trip without an end date.
*
* The two agree on any trip the planner wrote. Reading the days as well keeps
* the new day off a date another day already has, whatever state the range is in.
*/
export function nextTripDate(
endDate: string | null | undefined,
dayDates: readonly (string | null | undefined)[],
): string | null {
return endDate ? dayAfterLatest(endDate, dayDates) : null;
}
/** A day added with a date: the date it gets, and whether the trip may still grow to it. */
export interface DatedAppendPlan {
date: string;
fits: boolean;
}
/**
* What adding the next dated day would do to a trip. Null for a trip without
* dates, which only takes days without one. `fits` is false when the longer
* range would pass MAX_TRIP_DAYS. The server refuses by this and the planner
* greys its button by it, so the two cannot disagree about the limit.
*/
export function planDatedAppend(
trip: { start_date?: string | null; end_date?: string | null },
dayDates: readonly (string | null | undefined)[],
): DatedAppendPlan | null {
if (!trip.start_date || !trip.end_date) return null;
const date = dayAfterLatest(trip.end_date, dayDates);
return { date, fits: tripSpanDays(trip.start_date, date) <= MAX_TRIP_DAYS };
}